From 04fcdfe6bce788c30442868e32222e3c31332fdc Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Thu, 10 Jul 2025 18:37:57 +0200 Subject: [PATCH 1/8] feat(CommandPalette): add new component --- .cursor/rules/guidelines.mdc | 288 ++++++++ .junie/guidelines.md | 141 ++++ .../CommandPalette/CommandPalette.docs.mdx | 261 +++++++ .../CommandPalette/CommandPalette.spec.md | 278 ++++++++ .../CommandPalette/CommandPalette.stories.tsx | 652 ++++++++++++++++++ .../CommandPalette/CommandPalette.test.tsx | 459 ++++++++++++ .../actions/CommandPalette/CommandPalette.tsx | 619 +++++++++++++++++ .../actions/CommandPalette/index.ts | 2 + .../actions/CommandPalette/styled.tsx | 88 +++ .../{pickers => actions}/Menu/Menu.docs.mdx | 0 .../Menu/Menu.stories.tsx | 0 .../{pickers => actions}/Menu/Menu.test.tsx | 0 .../{pickers => actions}/Menu/Menu.tsx | 1 + .../{pickers => actions}/Menu/MenuItem.tsx | 10 +- .../{pickers => actions}/Menu/MenuSection.tsx | 0 .../{pickers => actions}/Menu/MenuTrigger.tsx | 0 .../{pickers => actions}/Menu/context.ts | 0 src/components/actions/Menu/index.ts | 9 + .../{pickers => actions}/Menu/styled.tsx | 2 +- src/components/actions/index.ts | 2 + src/components/fields/Select/Select.tsx | 6 +- src/components/pickers/Menu/MenuButton.tsx | 149 ---- src/index.ts | 10 +- 23 files changed, 2817 insertions(+), 160 deletions(-) create mode 100644 .cursor/rules/guidelines.mdc create mode 100644 .junie/guidelines.md create mode 100644 src/components/actions/CommandPalette/CommandPalette.docs.mdx create mode 100644 src/components/actions/CommandPalette/CommandPalette.spec.md create mode 100644 src/components/actions/CommandPalette/CommandPalette.stories.tsx create mode 100644 src/components/actions/CommandPalette/CommandPalette.test.tsx create mode 100644 src/components/actions/CommandPalette/CommandPalette.tsx create mode 100644 src/components/actions/CommandPalette/index.ts create mode 100644 src/components/actions/CommandPalette/styled.tsx rename src/components/{pickers => actions}/Menu/Menu.docs.mdx (100%) rename src/components/{pickers => actions}/Menu/Menu.stories.tsx (100%) rename src/components/{pickers => actions}/Menu/Menu.test.tsx (100%) rename src/components/{pickers => actions}/Menu/Menu.tsx (99%) rename src/components/{pickers => actions}/Menu/MenuItem.tsx (93%) rename src/components/{pickers => actions}/Menu/MenuSection.tsx (100%) rename src/components/{pickers => actions}/Menu/MenuTrigger.tsx (100%) rename src/components/{pickers => actions}/Menu/context.ts (100%) create mode 100644 src/components/actions/Menu/index.ts rename src/components/{pickers => actions}/Menu/styled.tsx (99%) delete mode 100644 src/components/pickers/Menu/MenuButton.tsx diff --git a/.cursor/rules/guidelines.mdc b/.cursor/rules/guidelines.mdc new file mode 100644 index 000000000..d99b480cc --- /dev/null +++ b/.cursor/rules/guidelines.mdc @@ -0,0 +1,288 @@ +--- +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 000000000..a4b71bfa3 --- /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/CommandPalette/CommandPalette.docs.mdx b/src/components/actions/CommandPalette/CommandPalette.docs.mdx new file mode 100644 index 000000000..df9d6fa96 --- /dev/null +++ b/src/components/actions/CommandPalette/CommandPalette.docs.mdx @@ -0,0 +1,261 @@ +import { Meta, Story, Controls } from '@storybook/blocks'; +import { CommandPalette } from './CommandPalette'; +import * as CommandPaletteStories from './CommandPalette.stories.tsx'; + + + +# CommandPalette + +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 CommandPalette 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 CommandPalette 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 CommandPalette component supports the following modifiers: + +| Modifier | Type | Description | +|----------|------|-------------| +| loading | `boolean` | Whether the command palette is in loading state | + +## Accessibility + +### Keyboard Navigation + +The CommandPalette 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 + +``` + +## Advanced Features + +### Enhanced Search + +The CommandPalette 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 + +### Integration with Dialog System + +```jsx +import { useDialogContainer } from '../../../hooks'; + +const CommandPaletteDialog = useDialogContainer(CommandPalette); + +// Usage + setIsOpen(false)} + searchPlaceholder="Search commands..." +> + Action 1 + +``` + +## 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/CommandPalette/CommandPalette.spec.md b/src/components/actions/CommandPalette/CommandPalette.spec.md new file mode 100644 index 000000000..49264e267 --- /dev/null +++ b/src/components/actions/CommandPalette/CommandPalette.spec.md @@ -0,0 +1,278 @@ +# CommandPalette Component Specification + +## Overview + +The CommandPalette 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 CommandPalette 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. **CommandPalette.tsx** - Main component implementation +2. **styled.tsx** - Styled components using tasty +3. **index.ts** - Export barrel (includes CommandPalette.Trigger alias) + +### Documentation & Testing +4. **CommandPalette.docs.mdx** - Component documentation +5. **CommandPalette.stories.tsx** - Storybook stories +6. **CommandPalette.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 CommandPalette 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**: CommandPalette 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(CommandPalette)` for programmatic usage - no need for a separate hook + +## Implementation Plan + +### Phase 1: Core Component Structure (CommandPalette.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 CommandPalette.Trigger alias** + - Export MenuTrigger as CommandPalette.Trigger in index.ts + - Ensure CommandPalette 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** + - CommandPaletteWrapper: 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 (CommandPalette.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(CommandPalette)` + - With MenuTrigger (using CommandPalette.Trigger alias) + - Custom filtering and keywords + - Multiple selection + - Loading states + - Force mount items + +### Phase 5: Stories (CommandPalette.stories.tsx) +1. **Create comprehensive stories** + - Default usage + - With header and footer + - Programmatic usage with `useDialogContainer(CommandPalette)` + - With MenuTrigger (CommandPalette.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 (CommandPalette.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 CommandPaletteProps 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 CommandPaletteItem { + // 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**: CommandPalette 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/CommandPalette/CommandPalette.stories.tsx b/src/components/actions/CommandPalette/CommandPalette.stories.tsx new file mode 100644 index 000000000..fd55a7d3e --- /dev/null +++ b/src/components/actions/CommandPalette/CommandPalette.stories.tsx @@ -0,0 +1,652 @@ +import { expect, userEvent, waitFor, within } from '@storybook/test'; +import React, { useState } from 'react'; + +import { Button } from '../Button'; +import { Menu } from '../Menu/Menu'; + +import { CommandPalette, CubeCommandPaletteProps } from './CommandPalette'; + +import type { StoryFn } from '@storybook/react'; + +export default { + title: 'Actions/CommandPalette', + component: CommandPalette, + 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 */ + styles: { + description: 'Custom styles for the command palette container', + }, + }, +}; + +const basicCommands = [ + { + key: 'copy', + label: 'Copy', + description: 'Copy selected text', + hotkeys: 'Ctrl+C', + }, + { + key: 'paste', + label: 'Paste', + description: 'Paste from clipboard', + hotkeys: 'Ctrl+V', + }, + { + key: 'cut', + label: 'Cut', + description: 'Cut selected text', + hotkeys: 'Ctrl+X', + }, + { + key: 'undo', + label: 'Undo', + description: 'Undo last action', + hotkeys: 'Ctrl+Z', + }, + { + key: 'redo', + label: 'Redo', + description: 'Redo last action', + hotkeys: 'Ctrl+Y', + }, + { + key: 'select-all', + label: 'Select All', + description: 'Select all text', + hotkeys: 'Ctrl+A', + }, +]; + +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([]); + + return ( +
+
+ Selected: {selectedKeys.join(', ') || 'None'} +
+ + {basicCommands.map((command) => ( + + {command.label} + + ))} + +
+ ); +}; + +MultipleSelection.args = { + searchPlaceholder: 'Select multiple commands...', + 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, +}; diff --git a/src/components/actions/CommandPalette/CommandPalette.test.tsx b/src/components/actions/CommandPalette/CommandPalette.test.tsx new file mode 100644 index 000000000..14cd5ce7f --- /dev/null +++ b/src/components/actions/CommandPalette/CommandPalette.test.tsx @@ -0,0 +1,459 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { CommandPalette } from './CommandPalette'; + +describe('CommandPalette', () => { + 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-palette-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-palette-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-palette-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).toBeInstanceOf(Set); + expect(selectionArg.has('1')).toBe(true); + }); + + 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('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'); + }); +}); diff --git a/src/components/actions/CommandPalette/CommandPalette.tsx b/src/components/actions/CommandPalette/CommandPalette.tsx new file mode 100644 index 000000000..46f3585d0 --- /dev/null +++ b/src/components/actions/CommandPalette/CommandPalette.tsx @@ -0,0 +1,619 @@ +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 CommandPalette compound component +import { Item, Section, useTreeState } from 'react-stately'; + +import { LoadingIcon, SearchIcon } 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 { + StyledCommandPalette, + StyledEmptyState, + StyledLoadingWrapper, + StyledMenuWrapper, + StyledSearchInput, + StyledSearchWrapper, +} from './styled'; + +export interface CommandPaletteItem { + // Standard item props + id: string; + textValue: string; + + // Enhanced search features + keywords?: string[]; + value?: string; + forceMount?: boolean; + + // Standard Menu item props inherited + [key: string]: any; +} + +export interface CubeCommandPaletteProps + extends BaseProps, + ContainerStyleProps, + 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; + + // Focus management - override the autoFocus from CubeMenuProps to allow boolean | FocusStrategy + autoFocus?: boolean | FocusStrategy; +} + +function CommandPaletteBase( + props: CubeCommandPaletteProps, + ref: DOMRef, +) { + const { + searchPlaceholder = 'Search commands...', + searchValue: controlledSearchValue, + onSearchChange, + filter: customFilter, + emptyLabel = 'No commands found', + searchInputStyles, + isLoading = false, + shouldFilter = true, + autoFocus = true, + qa, + styles, + ...restMenuProps + } = props; + + const domRef = useDOMRef(ref); + const searchInputRef = useRef(null); + const contextProps = useMenuContext(); + const completeProps = mergeProps(contextProps, restMenuProps); + + // 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), + ); + } + + // Check custom value if available + if (item?.value && typeof item.value === 'string') { + return textFilterFn(item.value, 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 CommandPalette + }; + + 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', + ); + + // Create a ref for the menu container + const menuRef = useRef(null); + + // Use menu hook for accessibility + const { menuProps } = useMenu( + { ...treeStateProps, 'aria-label': 'Command palette menu' }, + 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 CommandPalette 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]); + + // 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 _CommandPalette = React.forwardRef(CommandPaletteBase) as ( + props: CubeCommandPaletteProps & React.RefAttributes, +) => ReactElement; + +// Attach Trigger alias from MenuTrigger for consistent API +// Also attach Item and Section for compound component pattern +const __CommandPalette = Object.assign(_CommandPalette, { + Trigger: MenuTrigger, + Item, + Section, + displayName: 'CommandPalette', +}); + +export { __CommandPalette as CommandPalette }; diff --git a/src/components/actions/CommandPalette/index.ts b/src/components/actions/CommandPalette/index.ts new file mode 100644 index 000000000..ec60e00d3 --- /dev/null +++ b/src/components/actions/CommandPalette/index.ts @@ -0,0 +1,2 @@ +// Barrel file for CommandPalette component +export { CommandPalette } from './CommandPalette'; diff --git a/src/components/actions/CommandPalette/styled.tsx b/src/components/actions/CommandPalette/styled.tsx new file mode 100644 index 000000000..bc8eb391c --- /dev/null +++ b/src/components/actions/CommandPalette/styled.tsx @@ -0,0 +1,88 @@ +import { tasty } from '../../../tasty'; + +export const StyledCommandPalette = tasty({ + qa: 'CommandPalette', + 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 StyledSearchWrapper = tasty({ + qa: 'SearchWrapper', + styles: { + display: 'flex', + flow: 'row', + align: 'center', + padding: '1x', + border: '#border bottom', + fill: '#white', + gap: '.75x', + }, +}); + +export const StyledSearchInput = tasty({ + qa: 'SearchInput', + as: 'input', + styles: { + display: 'flex', + flex: 1, + border: 'none', + outline: 'none', + fill: 'transparent', + color: '#dark', + preset: 't3', + padding: '0', + + '&::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 100% rename from src/components/pickers/Menu/Menu.docs.mdx rename to src/components/actions/Menu/Menu.docs.mdx diff --git a/src/components/pickers/Menu/Menu.stories.tsx b/src/components/actions/Menu/Menu.stories.tsx similarity index 100% rename from src/components/pickers/Menu/Menu.stories.tsx rename to src/components/actions/Menu/Menu.stories.tsx diff --git a/src/components/pickers/Menu/Menu.test.tsx b/src/components/actions/Menu/Menu.test.tsx similarity index 100% rename from src/components/pickers/Menu/Menu.test.tsx rename to src/components/actions/Menu/Menu.test.tsx diff --git a/src/components/pickers/Menu/Menu.tsx b/src/components/actions/Menu/Menu.tsx similarity index 99% rename from src/components/pickers/Menu/Menu.tsx rename to src/components/actions/Menu/Menu.tsx index d1ef0d879..845e7950c 100644 --- a/src/components/pickers/Menu/Menu.tsx +++ b/src/components/actions/Menu/Menu.tsx @@ -52,6 +52,7 @@ export interface CubeMenuProps * This directly maps to the `autoFocus` option supported by React-Aria’s `useMenu` hook. */ autoFocus?: boolean | FocusStrategy; + shouldUseVirtualFocus?: boolean; } function Menu( diff --git a/src/components/pickers/Menu/MenuItem.tsx b/src/components/actions/Menu/MenuItem.tsx similarity index 93% rename from src/components/pickers/Menu/MenuItem.tsx rename to src/components/actions/Menu/MenuItem.tsx index 30ae6dd4c..ec9edb80e 100644 --- a/src/components/pickers/Menu/MenuItem.tsx +++ b/src/components/actions/Menu/MenuItem.tsx @@ -55,8 +55,9 @@ export function MenuItem(props: MenuItemProps) { const { onClose, closeOnSelect } = useMenuContext(); const { rendered, key, props: itemProps } = item; - // Extract optional keyboard shortcut from item props so it is not passed down to DOM elements. - const { hotkeys, wrapper, ...cleanItemProps } = (itemProps || {}) as any; + // Extract optional keyboard shortcut and CommandPalette-specific props from item props so they are not passed down to DOM elements. + const { hotkeys, wrapper, keywords, forceMount, ...cleanItemProps } = + (itemProps || {}) as any; const isSelectable = state.selectionManager.selectionMode !== 'none'; const isDisabledKey = state.disabledKeys.has(key); @@ -107,9 +108,11 @@ export function MenuItem(props: MenuItemProps) { ? getSelectionTypeIcon(selectionIcon) : undefined; + const isVirtualFocused = state.selectionManager.focusedKey === key; + const mods = { ...itemMods, - focused: isFocused, + focused: isFocused || isVirtualFocused, pressed: isPressed, selectionIcon: !!selectionIcon, selectable: isSelectable, @@ -132,6 +135,7 @@ export function MenuItem(props: MenuItemProps) { enableOnContentEditable: true, enabled: !!hotkeys, preventDefault: true, + enableOnFormTags: true, }, [hotkeys, isDisabledKey, isDisabled], ); diff --git a/src/components/pickers/Menu/MenuSection.tsx b/src/components/actions/Menu/MenuSection.tsx similarity index 100% rename from src/components/pickers/Menu/MenuSection.tsx rename to src/components/actions/Menu/MenuSection.tsx diff --git a/src/components/pickers/Menu/MenuTrigger.tsx b/src/components/actions/Menu/MenuTrigger.tsx similarity index 100% rename from src/components/pickers/Menu/MenuTrigger.tsx rename to src/components/actions/Menu/MenuTrigger.tsx diff --git a/src/components/pickers/Menu/context.ts b/src/components/actions/Menu/context.ts similarity index 100% rename from src/components/pickers/Menu/context.ts rename to src/components/actions/Menu/context.ts diff --git a/src/components/actions/Menu/index.ts b/src/components/actions/Menu/index.ts new file mode 100644 index 000000000..1a64f635e --- /dev/null +++ b/src/components/actions/Menu/index.ts @@ -0,0 +1,9 @@ +export * from './Menu'; +export * from './MenuTrigger'; +export * from './MenuItem'; +export * from './MenuSection'; +export * from './context'; + +// Re-export the main components +export { Menu } from './Menu'; +export { MenuTrigger } from './MenuTrigger'; diff --git a/src/components/pickers/Menu/styled.tsx b/src/components/actions/Menu/styled.tsx similarity index 99% rename from src/components/pickers/Menu/styled.tsx rename to src/components/actions/Menu/styled.tsx index acd9587e8..7d819f1c9 100644 --- a/src/components/pickers/Menu/styled.tsx +++ b/src/components/actions/Menu/styled.tsx @@ -1,5 +1,5 @@ +import { DEFAULT_BUTTON_STYLES, DEFAULT_NEUTRAL_STYLES } from '..'; import { tasty } from '../../../tasty'; -import { DEFAULT_BUTTON_STYLES, DEFAULT_NEUTRAL_STYLES } from '../../actions'; import { Space } from '../../layout/Space'; export const StyledMenu = tasty({ diff --git a/src/components/actions/index.ts b/src/components/actions/index.ts index 3978b2916..7ed1c74b0 100644 --- a/src/components/actions/index.ts +++ b/src/components/actions/index.ts @@ -10,5 +10,7 @@ const Button = Object.assign( export * from './Button'; export * from './Action/Action'; +export * from './Menu'; +export * from './CommandPalette'; export * from './use-action'; export { Button, ButtonGroup }; diff --git a/src/components/fields/Select/Select.tsx b/src/components/fields/Select/Select.tsx index 18f5aad7e..d95fa810e 100644 --- a/src/components/fields/Select/Select.tsx +++ b/src/components/fields/Select/Select.tsx @@ -59,13 +59,13 @@ import { SPECIAL_PRIMARY_STYLES, SPECIAL_SECONDARY_STYLES, } from '../../actions/index'; -import { useFieldProps, useFormProps, wrapWithField } from '../../form'; -import { OverlayWrapper } from '../../overlays/OverlayWrapper'; import { StyledDivider as ListDivider, StyledSectionHeading as ListSectionHeading, StyledSection as ListSectionWrapper, -} from '../../pickers/Menu/styled'; +} from '../../actions/Menu/styled'; +import { useFieldProps, useFormProps, wrapWithField } from '../../form'; +import { OverlayWrapper } from '../../overlays/OverlayWrapper'; import { InvalidIcon } from '../../shared/InvalidIcon'; import { ValidIcon } from '../../shared/ValidIcon'; import { DEFAULT_INPUT_STYLES, INPUT_WRAPPER_STYLES } from '../index'; diff --git a/src/components/pickers/Menu/MenuButton.tsx b/src/components/pickers/Menu/MenuButton.tsx deleted file mode 100644 index 826665af3..000000000 --- a/src/components/pickers/Menu/MenuButton.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { IconPointFilled } from '@tabler/icons-react'; -import { ReactElement, ReactNode } from 'react'; - -import { CheckIcon } from '../../../icons'; -import { tasty } from '../../../tasty'; -import { DEFAULT_BUTTON_STYLES, DEFAULT_NEUTRAL_STYLES } from '../../actions'; -import { Block, CubeBlockProps } from '../../Block'; -import { Text } from '../../content/Text'; -import { Space } from '../../layout/Space'; - -const StyledButton = tasty(Block, { - styles: { - ...DEFAULT_BUTTON_STYLES, - ...DEFAULT_NEUTRAL_STYLES, - height: 'min 4x', - border: '#clear', - fill: { - '': '#clear', - focused: '#dark.03', - selected: '#dark.06', - 'selected & focused': '#dark.09', - pressed: '#dark.06', - disabled: '#clear', - }, - color: { - '': '#dark-02', - 'selected | pressed': '#dark', - disabled: '#dark-04', - }, - cursor: { - '': 'pointer', - disabled: 'default', - }, - shadow: '#clear', - padding: { - '': '0 (1x - 1bw)', - 'selectionIcon & selectable & !selected': - '0 (1x - 1bw) 0 (1x - 1bw + 3x)', - }, - display: 'flex', - flow: 'row', - justifyContent: 'start', - gap: '.75x', - outline: false, - - ButtonIcon: { - display: 'grid', - fontSize: '@icon-size', - width: '@icon-size', - height: '@icon-size', - placeSelf: 'center', - placeItems: 'center', - }, - - Postfix: { - color: { - '': '#dark-03', - pressed: '#dark-02', - disabled: '#dark-04', - }, - }, - - Description: { - preset: 't4', - color: '#dark-03', - }, - }, -}); - -const getPostfix = (postfix) => - typeof postfix === 'string' ? ( - - {postfix} - - ) : ( - postfix - ); - -export type MenuSelectionType = 'checkbox' | 'radio'; - -export type MenuButtonProps = { - postfix?: ReactNode; - /** Optional description shown under the main label */ - description?: ReactNode; - selectionIcon?: MenuSelectionType; - isSelectable?: boolean; - isSelected?: boolean; - icon?: ReactElement; - onAction?: () => void; -} & CubeBlockProps; - -const getSelectionTypeIcon = (selectionIcon?: MenuSelectionType) => { - switch (selectionIcon) { - case 'checkbox': - return ; - case 'radio': - return ; - default: - return undefined; - } -}; - -export function MenuButton({ - children, - icon, - postfix, - description, - ...props -}: MenuButtonProps) { - const { selectionIcon, isSelected, isSelectable, isDisabled, ...rest } = - props; - const checkIcon = - isSelectable && isSelected - ? getSelectionTypeIcon(selectionIcon) - : undefined; - const mods = { - ...props.mods, - selectionIcon: !!selectionIcon, - selectable: isSelectable, - selected: isSelected, - disabled: isDisabled, - }; - - return ( - - {checkIcon ?
{checkIcon}
: null} - {icon ?
{icon}
: null} - - - - {children} - - {description ? ( - - {description} - - ) : null} - - {postfix && getPostfix(postfix)} - -
- ); -} diff --git a/src/index.ts b/src/index.ts index ad05d85b4..585c3ecee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -74,10 +74,12 @@ export * from './components/content/HotKeys'; 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'; -export type { CubeMenuTriggerProps } from './components/pickers/Menu/MenuTrigger'; +export { Menu } from './components/actions/Menu/Menu'; +export type { CubeMenuProps } from './components/actions/Menu/Menu'; +export { MenuTrigger } from './components/actions/Menu/MenuTrigger'; +export type { CubeMenuTriggerProps } from './components/actions/Menu/MenuTrigger'; +export { CommandPalette } from './components/actions/CommandPalette/CommandPalette'; +export type { CubeCommandPaletteProps } from './components/actions/CommandPalette/CommandPalette'; export { Select, ListBoxPopup } from './components/fields/Select/Select'; export type { CubeSelectProps, From 503a2e41491d5e993dc25e91fb2392af6e56a278 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Thu, 10 Jul 2025 18:53:36 +0200 Subject: [PATCH 2/8] feat(CommandPalette): add new component * 2 --- .../CommandPalette/CommandPalette.test.tsx | 105 ++++++++++++++++++ .../actions/CommandPalette/CommandPalette.tsx | 39 +++++++ 2 files changed, 144 insertions(+) diff --git a/src/components/actions/CommandPalette/CommandPalette.test.tsx b/src/components/actions/CommandPalette/CommandPalette.test.tsx index 14cd5ce7f..3f1507882 100644 --- a/src/components/actions/CommandPalette/CommandPalette.test.tsx +++ b/src/components/actions/CommandPalette/CommandPalette.test.tsx @@ -336,6 +336,111 @@ describe('CommandPalette', () => { }); }); + 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(/CommandPalette-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(/CommandPalette-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(); diff --git a/src/components/actions/CommandPalette/CommandPalette.tsx b/src/components/actions/CommandPalette/CommandPalette.tsx index 46f3585d0..97c41d0b8 100644 --- a/src/components/actions/CommandPalette/CommandPalette.tsx +++ b/src/components/actions/CommandPalette/CommandPalette.tsx @@ -247,6 +247,22 @@ function CommandPaletteBase( (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); @@ -369,6 +385,29 @@ function CommandPaletteBase( } }, [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), From f2e5c6575d2224457b31a7935572cb8f9fd74e0d Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Fri, 11 Jul 2025 09:41:45 +0200 Subject: [PATCH 3/8] feat(CommandPalette): add new component * 3 --- .../CommandPalette/CommandPalette.stories.tsx | 20 +++++++++---------- .../actions/CommandPalette/CommandPalette.tsx | 3 +-- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/components/actions/CommandPalette/CommandPalette.stories.tsx b/src/components/actions/CommandPalette/CommandPalette.stories.tsx index fd55a7d3e..391832c33 100644 --- a/src/components/actions/CommandPalette/CommandPalette.stories.tsx +++ b/src/components/actions/CommandPalette/CommandPalette.stories.tsx @@ -356,16 +356,16 @@ WithMenuTrigger.play = async ({ canvasElement, viewMode }) => { } }); - // 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'); - } - }); + // // 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> = ( diff --git a/src/components/actions/CommandPalette/CommandPalette.tsx b/src/components/actions/CommandPalette/CommandPalette.tsx index 97c41d0b8..a834f9e60 100644 --- a/src/components/actions/CommandPalette/CommandPalette.tsx +++ b/src/components/actions/CommandPalette/CommandPalette.tsx @@ -13,7 +13,7 @@ import { useFilter, useMenu } from 'react-aria'; // Import Item and Section from Menu for CommandPalette compound component import { Item, Section, useTreeState } from 'react-stately'; -import { LoadingIcon, SearchIcon } from '../../../icons'; +import { LoadingIcon } from '../../../icons'; import { BaseProps, CONTAINER_STYLES, @@ -430,7 +430,6 @@ function CommandPaletteBase( > {/* Search Input */} - Date: Fri, 11 Jul 2025 15:09:06 +0200 Subject: [PATCH 4/8] feat(CommandPalette): add new component * 4 --- .changeset/giant-drinks-love.md | 5 - .changeset/serious-meals-work.md | 5 + .cursor/rules/guidelines.mdc | 1 - .../CommandMenu.docs.mdx} | 60 ++-- .../CommandMenu.spec.md} | 52 +-- .../CommandMenu.stories.tsx} | 214 +++++++----- .../CommandMenu.test.tsx} | 212 +++++------ .../CommandMenu.tsx} | 328 +++++++++--------- src/components/actions/CommandMenu/index.ts | 4 + .../styled.tsx | 47 +-- .../actions/CommandPalette/index.ts | 2 - src/components/actions/Menu/Menu.stories.tsx | 45 +++ src/components/actions/Menu/Menu.tsx | 7 + src/components/actions/Menu/MenuItem.tsx | 7 +- src/components/actions/Menu/MenuSection.tsx | 4 +- src/components/actions/Menu/styled.tsx | 22 +- src/components/actions/index.ts | 2 +- .../fields/ComboBox/ComboBox.docs.mdx | 19 +- .../fields/ComboBox/ComboBox.stories.tsx | 7 +- src/components/fields/ComboBox/ComboBox.tsx | 6 +- src/components/fields/Select/Select.docs.mdx | 5 +- .../fields/Select/Select.stories.tsx | 7 +- src/components/fields/Select/Select.tsx | 20 +- src/index.ts | 7 +- 24 files changed, 623 insertions(+), 465 deletions(-) delete mode 100644 .changeset/giant-drinks-love.md create mode 100644 .changeset/serious-meals-work.md rename src/components/actions/{CommandPalette/CommandPalette.docs.mdx => CommandMenu/CommandMenu.docs.mdx} (84%) rename src/components/actions/{CommandPalette/CommandPalette.spec.md => CommandMenu/CommandMenu.spec.md} (80%) rename src/components/actions/{CommandPalette/CommandPalette.stories.tsx => CommandMenu/CommandMenu.stories.tsx} (76%) rename src/components/actions/{CommandPalette/CommandPalette.test.tsx => CommandMenu/CommandMenu.test.tsx} (75%) rename src/components/actions/{CommandPalette/CommandPalette.tsx => CommandMenu/CommandMenu.tsx} (67%) create mode 100644 src/components/actions/CommandMenu/index.ts rename src/components/actions/{CommandPalette => CommandMenu}/styled.tsx (69%) delete mode 100644 src/components/actions/CommandPalette/index.ts diff --git a/.changeset/giant-drinks-love.md b/.changeset/giant-drinks-love.md deleted file mode 100644 index 7a4ac7671..000000000 --- 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 000000000..166fb144f --- /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 index d99b480cc..2b7a43f33 100644 --- a/.cursor/rules/guidelines.mdc +++ b/.cursor/rules/guidelines.mdc @@ -2,7 +2,6 @@ alwaysApply: true --- - # Description Package name: `@cube-dev/ui-kit` diff --git a/src/components/actions/CommandPalette/CommandPalette.docs.mdx b/src/components/actions/CommandMenu/CommandMenu.docs.mdx similarity index 84% rename from src/components/actions/CommandPalette/CommandPalette.docs.mdx rename to src/components/actions/CommandMenu/CommandMenu.docs.mdx index df9d6fa96..254c5069f 100644 --- a/src/components/actions/CommandPalette/CommandPalette.docs.mdx +++ b/src/components/actions/CommandMenu/CommandMenu.docs.mdx @@ -1,10 +1,10 @@ import { Meta, Story, Controls } from '@storybook/blocks'; -import { CommandPalette } from './CommandPalette'; -import * as CommandPaletteStories from './CommandPalette.stories.tsx'; +import { CommandMenu } from './CommandMenu'; +import * as CommandMenuStories from './CommandMenu.stories'; - + -# CommandPalette +# 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. @@ -20,17 +20,17 @@ A searchable menu interface that combines the functionality of Menu and ListBox ### Default Usage - + ## Props - + ## Styling ### Style Props -The CommandPalette component supports all standard style properties: +The CommandMenu component supports all standard style properties: - **Layout**: `width`, `height`, `padding`, `margin` - **Positioning**: `position`, `top`, `left`, `right`, `bottom` @@ -41,7 +41,7 @@ The CommandPalette component supports all standard style properties: ### Sub-elements -The CommandPalette component has several sub-elements that can be styled: +The CommandMenu component has several sub-elements that can be styled: - `SearchWrapper` - Container for the search input area - `SearchInput` - The search input field specifically @@ -56,7 +56,7 @@ Customizes the search input field specifically. ### Modifiers -The CommandPalette component supports the following modifiers: +The CommandMenu component supports the following modifiers: | Modifier | Type | Description | |----------|------|-------------| @@ -66,7 +66,7 @@ The CommandPalette component supports the following modifiers: ### Keyboard Navigation -The CommandPalette component provides comprehensive keyboard support: +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 @@ -92,29 +92,29 @@ The CommandPalette component provides comprehensive keyboard support: ### Basic Usage ```jsx - + Copy Paste Cut - + ``` ### With MenuTrigger ```jsx - + - + Copy Paste - - + + ``` ### With Sections and Keywords ```jsx - + Copy @@ -128,7 +128,7 @@ The CommandPalette component provides comprehensive keyboard support: Zoom In - + ``` ### Controlled Search @@ -136,20 +136,20 @@ The CommandPalette component provides comprehensive keyboard support: ```jsx const [searchValue, setSearchValue] = useState(''); - Action 1 Action 2 - + ``` ### With Loading State ```jsx - @@ -158,13 +158,13 @@ const [searchValue, setSearchValue] = useState(''); {command.name} ))} - + ``` ### Custom Filtering ```jsx - { // Custom fuzzy search logic return textValue.toLowerCase().includes(inputValue.toLowerCase()); @@ -173,26 +173,26 @@ const [searchValue, setSearchValue] = useState(''); > Action 1 Action 2 - + ``` ### Force Mount Items ```jsx - + Help (always visible) Copy Paste - + ``` ## Advanced Features ### Enhanced Search -The CommandPalette supports enhanced search capabilities: +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 @@ -204,16 +204,16 @@ The CommandPalette supports enhanced search capabilities: ```jsx import { useDialogContainer } from '../../../hooks'; -const CommandPaletteDialog = useDialogContainer(CommandPalette); +const CommandMenuDialog = useDialogContainer(CommandMenu); // Usage - setIsOpen(false)} searchPlaceholder="Search commands..." > Action 1 - + ``` ## Best Practices diff --git a/src/components/actions/CommandPalette/CommandPalette.spec.md b/src/components/actions/CommandMenu/CommandMenu.spec.md similarity index 80% rename from src/components/actions/CommandPalette/CommandPalette.spec.md rename to src/components/actions/CommandMenu/CommandMenu.spec.md index 49264e267..9f91bace1 100644 --- a/src/components/actions/CommandPalette/CommandPalette.spec.md +++ b/src/components/actions/CommandMenu/CommandMenu.spec.md @@ -1,8 +1,8 @@ -# CommandPalette Component Specification +# CommandMenu Component Specification ## Overview -The CommandPalette 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. +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 @@ -18,7 +18,7 @@ The CommandPalette component is a searchable menu interface that combines the fu - **Enhanced search**: Keywords-based matching and custom value support ### Key Behaviors -- When CommandPalette gains focus, the search input is automatically focused +- 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 @@ -32,14 +32,14 @@ The CommandPalette component is a searchable menu interface that combines the fu ## Required Files ### Core Component Files -1. **CommandPalette.tsx** - Main component implementation +1. **CommandMenu.tsx** - Main component implementation 2. **styled.tsx** - Styled components using tasty -3. **index.ts** - Export barrel (includes CommandPalette.Trigger alias) +3. **index.ts** - Export barrel (includes CommandMenu.Trigger alias) ### Documentation & Testing -4. **CommandPalette.docs.mdx** - Component documentation -5. **CommandPalette.stories.tsx** - Storybook stories -6. **CommandPalette.test.tsx** - Unit tests (10-15 comprehensive tests) +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 @@ -47,18 +47,18 @@ The CommandPalette component is a searchable menu interface that combines the fu ## Implementation Approach -The CommandPalette 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. +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**: CommandPalette will wrap the existing Menu component to inherit all features (sections, descriptions, tooltips, hotkeys) +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(CommandPalette)` for programmatic usage - no need for a separate hook +4. **Reuse existing patterns**: Use `useDialogContainer(CommandMenu)` for programmatic usage - no need for a separate hook ## Implementation Plan -### Phase 1: Core Component Structure (CommandPalette.tsx) +### Phase 1: Core Component Structure (CommandMenu.tsx) 1. **Setup component interface** - Extend Menu props with search-specific additions - Add `searchPlaceholder`, `emptyLabel`, `filter` props @@ -92,9 +92,9 @@ The CommandPalette will **reuse the existing Menu component** and add search fun - Empty state when no results ### Phase 2: MenuTrigger Integration and Alias -1. **Create CommandPalette.Trigger alias** - - Export MenuTrigger as CommandPalette.Trigger in index.ts - - Ensure CommandPalette works seamlessly with MenuTrigger +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** @@ -104,7 +104,7 @@ The CommandPalette will **reuse the existing Menu component** and add search fun ### Phase 3: Styling (styled.tsx) 1. **Create styled components using tasty** - - CommandPaletteWrapper: Main container + - CommandMenuWrapper: Main container - SearchSection: Search input area - LoadingSection: Loading indicator area - ContentSection: Options list area @@ -117,7 +117,7 @@ The CommandPalette will **reuse the existing Menu component** and add search fun - Proper spacing and typography - Theme integration -### Phase 4: Documentation (CommandPalette.docs.mdx) +### Phase 4: Documentation (CommandMenu.docs.mdx) 1. **Follow documentation guidelines** - Component overview and when to use - Complete props documentation @@ -128,19 +128,19 @@ The CommandPalette will **reuse the existing Menu component** and add search fun 2. **Include comprehensive examples** - Basic usage - With header/footer - - Programmatic usage with `useDialogContainer(CommandPalette)` - - With MenuTrigger (using CommandPalette.Trigger alias) + - 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 (CommandPalette.stories.tsx) +### Phase 5: Stories (CommandMenu.stories.tsx) 1. **Create comprehensive stories** - Default usage - With header and footer - - Programmatic usage with `useDialogContainer(CommandPalette)` - - With MenuTrigger (CommandPalette.Trigger) + - Programmatic usage with `useDialogContainer(CommandMenu)` + - With MenuTrigger (CommandMenu.Trigger) - Custom filtering and keywords - Loading states - Empty states @@ -152,7 +152,7 @@ The CommandPalette will **reuse the existing Menu component** and add search fun - Demonstrate keyboard navigation - Show filtering behavior -### Phase 6: Testing (CommandPalette.test.tsx) +### Phase 6: Testing (CommandMenu.test.tsx) 1. **Functional tests (10-15 tests)** - Basic rendering and props - Search functionality and filtering @@ -181,7 +181,7 @@ The CommandPalette will **reuse the existing Menu component** and add search fun ### Props Interface ```typescript -interface CommandPaletteProps extends CubeMenuProps { +interface CommandMenuProps extends CubeMenuProps { // Search-specific props searchPlaceholder?: string; searchValue?: string; @@ -202,7 +202,7 @@ interface CommandPaletteProps extends CubeMenuProps { // - All React Aria menu props (isDisabled, disabledKeys, etc.) } -interface CommandPaletteItem { +interface CommandMenuItem { // Standard item props id: string; textValue: string; @@ -241,7 +241,7 @@ interface CommandPaletteItem { ## Integration Points ### With Menu Component -- **Direct reuse**: CommandPalette wraps Menu component completely +- **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 diff --git a/src/components/actions/CommandPalette/CommandPalette.stories.tsx b/src/components/actions/CommandMenu/CommandMenu.stories.tsx similarity index 76% rename from src/components/actions/CommandPalette/CommandPalette.stories.tsx rename to src/components/actions/CommandMenu/CommandMenu.stories.tsx index 391832c33..4a596d0de 100644 --- a/src/components/actions/CommandPalette/CommandPalette.stories.tsx +++ b/src/components/actions/CommandMenu/CommandMenu.stories.tsx @@ -1,16 +1,17 @@ import { expect, userEvent, waitFor, within } from '@storybook/test'; import React, { useState } from 'react'; +import { Dialog, DialogTrigger } from '../../overlays/Dialog'; import { Button } from '../Button'; import { Menu } from '../Menu/Menu'; -import { CommandPalette, CubeCommandPaletteProps } from './CommandPalette'; +import { CommandMenu, CubeCommandMenuProps } from './CommandMenu'; import type { StoryFn } from '@storybook/react'; export default { - title: 'Actions/CommandPalette', - component: CommandPalette, + title: 'Actions/CommandMenu', + component: CommandMenu, parameters: { docs: { description: { @@ -96,6 +97,15 @@ export default { }, /* 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', }, @@ -257,18 +267,18 @@ const extendedCommands = [ }, ]; -export const Default: StoryFn> = (args) => ( - +export const Default: StoryFn> = (args) => ( + {basicCommands.map((command) => ( - {command.label} - + ))} - + ); Default.args = { @@ -276,7 +286,7 @@ Default.args = { autoFocus: true, }; -export const WithSections: StoryFn> = (args) => { +export const WithSections: StoryFn> = (args) => { const commandsBySection = extendedCommands.reduce( (acc, command) => { const section = command.section || 'Other'; @@ -288,11 +298,11 @@ export const WithSections: StoryFn> = (args) => { ); return ( - + {Object.entries(commandsBySection).map(([sectionName, commands]) => ( {commands.map((command) => ( - > = (args) => { forceMount={command.forceMount} > {command.label} - + ))} ))} - + ); }; @@ -313,23 +323,21 @@ WithSections.args = { autoFocus: true, }; -export const WithMenuTrigger: StoryFn> = ( - args, -) => ( - +export const WithMenuTrigger: StoryFn> = (args) => ( + - + {basicCommands.map((command) => ( - {command.label} - + ))} - - + + ); WithMenuTrigger.args = { @@ -368,9 +376,7 @@ WithMenuTrigger.play = async ({ canvasElement, viewMode }) => { // }); }; -export const ControlledSearch: StoryFn> = ( - args, -) => { +export const ControlledSearch: StoryFn> = (args) => { const [searchValue, setSearchValue] = useState(''); return ( @@ -378,21 +384,21 @@ export const ControlledSearch: StoryFn> = (
Current search: "{searchValue}"
- {basicCommands.map((command) => ( - {command.label} - + ))} - + ); }; @@ -402,18 +408,18 @@ ControlledSearch.args = { autoFocus: true, }; -export const LoadingState: StoryFn> = (args) => ( - +export const LoadingState: StoryFn> = (args) => ( + {basicCommands.map((command) => ( - {command.label} - + ))} - + ); LoadingState.args = { @@ -422,8 +428,8 @@ LoadingState.args = { autoFocus: true, }; -export const CustomFilter: StoryFn> = (args) => ( - > = (args) => ( + { // Custom fuzzy search - matches if all characters of input appear in order @@ -442,15 +448,15 @@ export const CustomFilter: StoryFn> = (args) => ( }} > {basicCommands.map((command) => ( - {command.label} - + ))} - + ); CustomFilter.args = { @@ -458,24 +464,21 @@ CustomFilter.args = { autoFocus: true, }; -export const WithKeywords: StoryFn> = (args) => ( - - +export const WithKeywords: StoryFn> = (args) => ( + + Copy - - + + Paste - - + + Save File - - + + Open File - - + + ); WithKeywords.args = { @@ -483,26 +486,24 @@ WithKeywords.args = { autoFocus: true, }; -export const ForceMountItems: StoryFn> = ( - args, -) => ( - - +export const ForceMountItems: StoryFn> = (args) => ( + + Help (always visible) - - + + Settings (always visible) - + {basicCommands.map((command) => ( - {command.label} - + ))} - + ); ForceMountItems.args = { @@ -510,11 +511,11 @@ ForceMountItems.args = { autoFocus: true, }; -export const EmptyState: StoryFn> = (args) => ( - - Copy - Paste - +export const EmptyState: StoryFn> = (args) => ( + + Copy + Paste + ); EmptyState.args = { @@ -523,9 +524,7 @@ EmptyState.args = { autoFocus: true, }; -export const MultipleSelection: StoryFn> = ( - args, -) => { +export const MultipleSelection: StoryFn> = (args) => { const [selectedKeys, setSelectedKeys] = useState([]); return ( @@ -533,22 +532,22 @@ export const MultipleSelection: StoryFn> = (
Selected: {selectedKeys.join(', ') || 'None'}
- {basicCommands.map((command) => ( - {command.label} - + ))} - + ); }; @@ -558,8 +557,8 @@ MultipleSelection.args = { autoFocus: true, }; -export const CustomStyling: StoryFn> = (args) => ( - > = (args) => ( + > = (args) => ( }} > {basicCommands.map((command) => ( - {command.label} - + ))} - + ); CustomStyling.args = { @@ -588,7 +587,7 @@ CustomStyling.args = { autoFocus: true, }; -export const HotkeyTesting: StoryFn> = (args) => { +export const HotkeyTesting: StoryFn> = (args) => { const [lastAction, setLastAction] = useState(null); const handleAction = (key: string) => { @@ -631,17 +630,17 @@ export const HotkeyTesting: StoryFn> = (args) => { )} - + {basicCommands.map((command) => ( - {command.label} - + ))} - + ); }; @@ -650,3 +649,46 @@ 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, +}; diff --git a/src/components/actions/CommandPalette/CommandPalette.test.tsx b/src/components/actions/CommandMenu/CommandMenu.test.tsx similarity index 75% rename from src/components/actions/CommandPalette/CommandPalette.test.tsx rename to src/components/actions/CommandMenu/CommandMenu.test.tsx index 3f1507882..a4c64162c 100644 --- a/src/components/actions/CommandPalette/CommandPalette.test.tsx +++ b/src/components/actions/CommandMenu/CommandMenu.test.tsx @@ -2,9 +2,9 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { CommandPalette } from './CommandPalette'; +import { CommandMenu } from './CommandMenu'; -describe('CommandPalette', () => { +describe('CommandMenu', () => { const items = [ { id: '1', textValue: 'Create file' }, { id: '2', textValue: 'Open folder' }, @@ -13,13 +13,13 @@ describe('CommandPalette', () => { it('renders with search input and menu items', () => { render( - + {items.map((item) => ( - + {item.textValue} - + ))} - , + , ); expect( @@ -34,13 +34,13 @@ describe('CommandPalette', () => { const user = userEvent.setup(); render( - + {items.map((item) => ( - + {item.textValue} - + ))} - , + , ); const searchInput = screen.getByPlaceholderText('Search commands...'); @@ -55,13 +55,13 @@ describe('CommandPalette', () => { const user = userEvent.setup(); render( - + {items.map((item) => ( - + {item.textValue} - + ))} - , + , ); const searchInput = screen.getByPlaceholderText('Search commands...'); @@ -79,7 +79,7 @@ describe('CommandPalette', () => { // First item should be virtually focused (aria-activedescendant) expect(searchInput).toHaveAttribute( 'aria-activedescendant', - 'test-palette-menu-option-1', + 'test-menu-menu-option-1', ); // Press arrow down again - should move to second item @@ -91,7 +91,7 @@ describe('CommandPalette', () => { // Second item should be virtually focused expect(searchInput).toHaveAttribute( 'aria-activedescendant', - 'test-palette-menu-option-2', + 'test-menu-menu-option-2', ); // Press arrow up - should move back to first item @@ -103,7 +103,7 @@ describe('CommandPalette', () => { // First item should be virtually focused again expect(searchInput).toHaveAttribute( 'aria-activedescendant', - 'test-palette-menu-option-1', + 'test-menu-menu-option-1', ); }); @@ -112,13 +112,13 @@ describe('CommandPalette', () => { const onAction = jest.fn(); render( - + {items.map((item) => ( - + {item.textValue} - + ))} - , + , ); const searchInput = screen.getByPlaceholderText('Search commands...'); @@ -136,17 +136,17 @@ describe('CommandPalette', () => { const onSelectionChange = jest.fn(); render( - {items.map((item) => ( - + {item.textValue} - + ))} - , + , ); const searchInput = screen.getByPlaceholderText('Search commands...'); @@ -166,13 +166,13 @@ describe('CommandPalette', () => { const user = userEvent.setup(); render( - + {items.map((item) => ( - + {item.textValue} - + ))} - , + , ); const searchInput = screen.getByPlaceholderText('Search commands...'); @@ -187,13 +187,13 @@ describe('CommandPalette', () => { const user = userEvent.setup(); render( - + {items.map((item) => ( - + {item.textValue} - + ))} - , + , ); const searchInput = screen.getByPlaceholderText('Search commands...'); @@ -204,13 +204,13 @@ describe('CommandPalette', () => { it('shows loading state', () => { render( - + {items.map((item) => ( - + {item.textValue} - + ))} - , + , ); expect(screen.getByRole('progressbar')).toBeInTheDocument(); @@ -223,13 +223,13 @@ describe('CommandPalette', () => { }; render( - + {items.map((item) => ( - + {item.textValue} - + ))} - , + , ); const searchInput = screen.getByPlaceholderText('Search commands...'); @@ -243,12 +243,12 @@ describe('CommandPalette', () => { const user = userEvent.setup(); render( - - + + Create file - - Open folder - , + + Open folder + , ); const searchInput = screen.getByPlaceholderText('Search commands...'); @@ -262,12 +262,12 @@ describe('CommandPalette', () => { const user = userEvent.setup(); render( - - + + Always visible - - Sometimes visible - , + + Sometimes visible + , ); const searchInput = screen.getByPlaceholderText('Search commands...'); @@ -279,15 +279,15 @@ describe('CommandPalette', () => { it('supports sections', () => { render( - - - Create file - Open folder - - - Save document - - , + + + Create file + Open folder + + + Save document + + , ); expect(screen.getByText('File Operations')).toBeInTheDocument(); @@ -301,13 +301,13 @@ describe('CommandPalette', () => { const onSearchChange = jest.fn(); render( - + {items.map((item) => ( - + {item.textValue} - + ))} - , + , ); const searchInput = screen.getByPlaceholderText('Search commands...'); @@ -321,13 +321,13 @@ describe('CommandPalette', () => { it('auto-focuses search input when autoFocus is true', async () => { render( - + {items.map((item) => ( - + {item.textValue} - + ))} - , + , ); const searchInput = screen.getByPlaceholderText('Search commands...'); @@ -340,13 +340,13 @@ describe('CommandPalette', () => { const user = userEvent.setup(); render( - + {items.map((item) => ( - + {item.textValue} - + ))} - , + , ); const searchInput = screen.getByPlaceholderText('Search commands...'); @@ -363,7 +363,7 @@ describe('CommandPalette', () => { const activeDescendant = searchInput.getAttribute( 'aria-activedescendant', ); - expect(activeDescendant).toMatch(/CommandPalette-menu-option-1/); + expect(activeDescendant).toMatch(/CommandMenu-menu-option-1/); }); }); @@ -371,13 +371,13 @@ describe('CommandPalette', () => { const user = userEvent.setup(); render( - + {items.map((item) => ( - + {item.textValue} - + ))} - , + , ); const searchInput = screen.getByPlaceholderText('Search commands...'); @@ -405,13 +405,13 @@ describe('CommandPalette', () => { const user = userEvent.setup(); render( - + {items.map((item) => ( - + {item.textValue} - + ))} - , + , ); const searchInput = screen.getByPlaceholderText('Search commands...'); @@ -425,7 +425,7 @@ describe('CommandPalette', () => { const activeDescendant = searchInput.getAttribute( 'aria-activedescendant', ); - expect(activeDescendant).toMatch(/CommandPalette-menu-option-1/); + expect(activeDescendant).toMatch(/CommandMenu-menu-option-1/); }); // Clear and type something that won't match @@ -445,13 +445,13 @@ describe('CommandPalette', () => { const user = userEvent.setup(); render( - + {items.map((item) => ( - + {item.textValue} - + ))} - , + , ); const searchInput = screen.getByPlaceholderText('Search commands...'); @@ -462,13 +462,13 @@ describe('CommandPalette', () => { it('supports custom search placeholder', () => { render( - + {items.map((item) => ( - + {item.textValue} - + ))} - , + , ); expect( @@ -480,14 +480,14 @@ describe('CommandPalette', () => { const onAction = jest.fn(); const { container } = render( - - + + Copy - - + + Paste - - , + + , ); // Verify that hotkey elements are rendered @@ -509,11 +509,11 @@ describe('CommandPalette', () => { const onAction = jest.fn(); render( - - + + Copy - - , + + , ); // Click the menu item directly to verify action works @@ -531,14 +531,14 @@ describe('CommandPalette', () => { const onAction = jest.fn(); render( - - + + Copy - - + + Paste - - , + + , ); // Focus the search input to simulate real usage diff --git a/src/components/actions/CommandPalette/CommandPalette.tsx b/src/components/actions/CommandMenu/CommandMenu.tsx similarity index 67% rename from src/components/actions/CommandPalette/CommandPalette.tsx rename to src/components/actions/CommandMenu/CommandMenu.tsx index a834f9e60..458b313d3 100644 --- a/src/components/actions/CommandPalette/CommandPalette.tsx +++ b/src/components/actions/CommandMenu/CommandMenu.tsx @@ -10,7 +10,7 @@ import React, { useState, } from 'react'; import { useFilter, useMenu } from 'react-aria'; -// Import Item and Section from Menu for CommandPalette compound component +// Import Item and Section from Menu for CommandMenu compound component import { Item, Section, useTreeState } from 'react-stately'; import { LoadingIcon } from '../../../icons'; @@ -32,15 +32,14 @@ import { MenuTrigger } from '../Menu/MenuTrigger'; import { StyledDivider, StyledMenu } from '../Menu/styled'; import { - StyledCommandPalette, + StyledCommandMenu, StyledEmptyState, StyledLoadingWrapper, StyledMenuWrapper, StyledSearchInput, - StyledSearchWrapper, } from './styled'; -export interface CommandPaletteItem { +export interface CommandMenuItem { // Standard item props id: string; textValue: string; @@ -54,7 +53,7 @@ export interface CommandPaletteItem { [key: string]: any; } -export interface CubeCommandPaletteProps +export interface CubeCommandMenuProps extends BaseProps, ContainerStyleProps, CubeMenuProps { @@ -72,10 +71,13 @@ export interface CubeCommandPaletteProps // Focus management - override the autoFocus from CubeMenuProps to allow boolean | FocusStrategy autoFocus?: boolean | FocusStrategy; + + // Size prop + size?: 'small' | 'medium' | (string & {}); } -function CommandPaletteBase( - props: CubeCommandPaletteProps, +function CommandMenuBase( + props: CubeCommandMenuProps, ref: DOMRef, ) { const { @@ -88,6 +90,7 @@ function CommandPaletteBase( isLoading = false, shouldFilter = true, autoFocus = true, + size = 'small', qa, styles, ...restMenuProps @@ -199,7 +202,7 @@ function CommandPaletteBase( const treeStateProps = { ...completeProps, filter: collectionFilter, - shouldUseVirtualFocus: true, // Always use virtual focus for CommandPalette + shouldUseVirtualFocus: true, // Always use virtual focus for CommandMenu }; const treeState = useTreeState(treeStateProps); @@ -299,6 +302,7 @@ function CommandPaletteBase( itemStyles={completeProps.itemStyles} headingStyles={completeProps.sectionHeadingStyles} selectionIcon={completeProps.selectionIcon} + size={size} />, ); @@ -313,6 +317,7 @@ function CommandPaletteBase( state={treeState} styles={completeProps.itemStyles} selectionIcon={completeProps.selectionIcon} + size={size} onAction={item.onAction} /> ); @@ -359,7 +364,7 @@ function CommandPaletteBase( React.useEffect(() => { if (autoFocus && searchInputRef.current) { // Use a small timeout to ensure the element is visible and focusable - // This is especially important when the CommandPalette is opened in a popover + // This is especially important when the CommandMenu is opened in a popover const timeoutId = setTimeout(() => { if (searchInputRef.current) { searchInputRef.current.focus(); @@ -422,178 +427,178 @@ function CommandPaletteBase( useSyncRef(contextProps, menuRef); return ( - {/* Search Input */} - - handleSearchChange(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { - e.preventDefault(); + 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; + } - 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; + } - // 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 startKey is not selectable, find the next selectable key - let keys = [...collection.getKeys()]; + if (direction === 'backward') { + keys = keys.reverse(); + } - if (direction === 'backward') { - keys = keys.reverse(); - } + let startIndex = keys.indexOf(startKey); + + if (startIndex === -1) { + return null; + } - let startIndex = keys.indexOf(startKey); + for (let i = startIndex + 1; i < keys.length; i++) { + const key = keys[i]; + const node = collection.getItem(key); - if (startIndex === -1) { - return null; + if ( + node && + node.type === 'item' && + !selectionManager.isDisabled(key) + ) { + return key; } + } - for (let i = startIndex + 1; i < keys.length; i++) { - const key = keys[i]; - const node = collection.getItem(key); + return null; + }; - if ( - node && - node.type === 'item' && - !selectionManager.isDisabled(key) - ) { - return key; - } - } + // 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(); - 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; - } + for (const key of keysToCheck) { + const node = collection.getItem(key); + + if ( + node && + node.type === 'item' && + !selectionManager.isDisabled(key) + ) { + return key; } + } - return null; - }; + return null; + }; - let nextKey; - const direction = isArrowDown ? 'forward' : 'backward'; + 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); + 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); + 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 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); - } - } - } + 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(); - // 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(); + // 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); + } } } - } else if (e.key === 'Escape') { - if (searchValue) { - e.preventDefault(); - handleSearchChange(''); + + // 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 && ( @@ -612,9 +617,10 @@ function CommandPaletteBase( ( {!isLoading && showEmptyState && ( {emptyLabel} )} - + ); } // forwardRef doesn't support generic parameters, so cast the result to the correct type -const _CommandPalette = React.forwardRef(CommandPaletteBase) as ( - props: CubeCommandPaletteProps & React.RefAttributes, +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 __CommandPalette = Object.assign(_CommandPalette, { +const __CommandMenu = Object.assign(_CommandMenu, { Trigger: MenuTrigger, Item, Section, - displayName: 'CommandPalette', + displayName: 'CommandMenu', }); -export { __CommandPalette as CommandPalette }; +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 000000000..5e3ca33d4 --- /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/CommandPalette/styled.tsx b/src/components/actions/CommandMenu/styled.tsx similarity index 69% rename from src/components/actions/CommandPalette/styled.tsx rename to src/components/actions/CommandMenu/styled.tsx index bc8eb391c..652e55fd1 100644 --- a/src/components/actions/CommandPalette/styled.tsx +++ b/src/components/actions/CommandMenu/styled.tsx @@ -1,7 +1,7 @@ import { tasty } from '../../../tasty'; -export const StyledCommandPalette = tasty({ - qa: 'CommandPalette', +export const StyledCommandMenu = tasty({ + qa: 'CommandMenu', styles: { display: 'grid', flow: 'row', @@ -18,31 +18,38 @@ export const StyledCommandPalette = tasty({ }, }); -export const StyledSearchWrapper = tasty({ - qa: 'SearchWrapper', - styles: { - display: 'flex', - flow: 'row', - align: 'center', - padding: '1x', - border: '#border bottom', - fill: '#white', - gap: '.75x', - }, -}); - export const StyledSearchInput = tasty({ qa: 'SearchInput', as: 'input', styles: { display: 'flex', - flex: 1, - border: 'none', - outline: 'none', - fill: 'transparent', + 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', - padding: '0', + 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', diff --git a/src/components/actions/CommandPalette/index.ts b/src/components/actions/CommandPalette/index.ts deleted file mode 100644 index ec60e00d3..000000000 --- a/src/components/actions/CommandPalette/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Barrel file for CommandPalette component -export { CommandPalette } from './CommandPalette'; diff --git a/src/components/actions/Menu/Menu.stories.tsx b/src/components/actions/Menu/Menu.stories.tsx index 3e42d6b2c..916f08436 100644 --- a/src/components/actions/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} + + + + + + + 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 diff --git a/src/components/actions/CommandMenu/CommandMenu.stories.tsx b/src/components/actions/CommandMenu/CommandMenu.stories.tsx index 4a596d0de..f54ccf89a 100644 --- a/src/components/actions/CommandMenu/CommandMenu.stories.tsx +++ b/src/components/actions/CommandMenu/CommandMenu.stories.tsx @@ -1,7 +1,24 @@ import { expect, userEvent, waitFor, within } from '@storybook/test'; +import { + IconArrowBack, + IconArrowForward, + IconClipboard, + IconCopy, + IconCut, + IconDeviceFloppy, + IconFile, + IconFileText, + IconFolder, + IconSearch, + IconSettings, +} from '@tabler/icons-react'; import React, { useState } from 'react'; -import { Dialog, DialogTrigger } from '../../overlays/Dialog'; +import { + Dialog, + DialogTrigger, + useDialogContainer, +} from '../../overlays/Dialog'; import { Button } from '../Button'; import { Menu } from '../Menu/Menu'; @@ -557,6 +574,41 @@ MultipleSelection.args = { 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) => ( > = (args) => ( - + {basicCommands.map((command) => ( { + 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...'); + }); +}; + +export const WithIcons: StoryFn> = (args) => ( + + + } + description="Create a new file" + hotkeys="Ctrl+N" + > + New File + + } + description="Open an existing file" + hotkeys="Ctrl+O" + > + Open File + + } + description="Save current file" + hotkeys="Ctrl+S" + > + Save File + + + + + } + description="Copy selected text" + hotkeys="Ctrl+C" + keywords={['duplicate', 'clone']} + > + Copy + + } + description="Paste from clipboard" + hotkeys="Ctrl+V" + keywords={['insert']} + > + Paste + + } + description="Cut selected text" + hotkeys="Ctrl+X" + > + Cut + + } + description="Undo last action" + hotkeys="Ctrl+Z" + > + Undo + + } + description="Redo last action" + hotkeys="Ctrl+Y" + > + Redo + + + + + } + description="Search in files" + hotkeys="Ctrl+F" + > + Search + + } + description="Open settings" + hotkeys="Ctrl+." + > + Settings + + + Documents + + + +); + +WithIcons.args = { + searchPlaceholder: 'Search commands with icons...', + autoFocus: true, +}; diff --git a/src/components/actions/CommandMenu/CommandMenu.test.tsx b/src/components/actions/CommandMenu/CommandMenu.test.tsx index a4c64162c..aab7516f2 100644 --- a/src/components/actions/CommandMenu/CommandMenu.test.tsx +++ b/src/components/actions/CommandMenu/CommandMenu.test.tsx @@ -158,8 +158,7 @@ describe('CommandMenu', () => { expect(onSelectionChange).toHaveBeenCalledTimes(1); const selectionArg = onSelectionChange.mock.calls[0][0]; - expect(selectionArg).toBeInstanceOf(Set); - expect(selectionArg.has('1')).toBe(true); + expect(selectionArg).toEqual(['1']); }); it('clears search with Escape key', async () => { @@ -561,4 +560,69 @@ describe('CommandMenu', () => { 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 index 458b313d3..5e02067ff 100644 --- a/src/components/actions/CommandMenu/CommandMenu.tsx +++ b/src/components/actions/CommandMenu/CommandMenu.tsx @@ -56,7 +56,10 @@ export interface CommandMenuItem { export interface CubeCommandMenuProps extends BaseProps, ContainerStyleProps, - CubeMenuProps { + Omit< + CubeMenuProps, + 'selectedKeys' | 'defaultSelectedKeys' | 'onSelectionChange' + > { // Search-specific props searchPlaceholder?: string; searchValue?: string; @@ -74,6 +77,13 @@ export interface CubeCommandMenuProps // 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( @@ -93,13 +103,42 @@ function CommandMenuBase( size = 'small', qa, styles, + selectedKeys, + defaultSelectedKeys, + onSelectionChange, ...restMenuProps } = props; const domRef = useDOMRef(ref); const searchInputRef = useRef(null); const contextProps = useMenuContext(); - const completeProps = mergeProps(contextProps, restMenuProps); + + // Convert string[] to Set for React Aria compatibility + const ariaSelectedKeys = selectedKeys ? new Set(selectedKeys) : undefined; + const ariaDefaultSelectedKeys = defaultSelectedKeys + ? new Set(defaultSelectedKeys) + : undefined; + + // Convert Set to string[] for the callback + const handleSelectionChange = onSelectionChange + ? (keys: any) => { + if (keys === 'all') { + // Handle 'all' selection case - for now, we'll pass an empty array + // This is a rare edge case that might need special handling + onSelectionChange([]); + } 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(''); @@ -222,7 +261,7 @@ function CommandMenuBase( const filterNodes = (items: any[]): any[] => { const result: any[] = []; - items.forEach((item) => { + [...items].forEach((item) => { if (item.type === 'section') { const filteredChildren = filterNodes(item.childNodes); if (filteredChildren.length) { diff --git a/src/components/actions/Menu/Menu.docs.mdx b/src/components/actions/Menu/Menu.docs.mdx index 9779fc5b6..1324403bf 100644 --- a/src/components/actions/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/actions/Menu/Menu.stories.tsx b/src/components/actions/Menu/Menu.stories.tsx index 916f08436..047e35fbf 100644 --- a/src/components/actions/Menu/Menu.stories.tsx +++ b/src/components/actions/Menu/Menu.stories.tsx @@ -504,8 +504,8 @@ export const GitActions = (props) => { export const MenuSelectableSingle = (props) => { const [selectedKeys, setSelectedKeys] = useState(['1']); - const onSelectionChange = (key) => { - setSelectedKeys(key); + const onSelectionChange = (keys) => { + setSelectedKeys(keys); }; return MenuTemplate({ @@ -518,8 +518,8 @@ export const MenuSelectableSingle = (props) => { export const MenuSelectableMultiple = (props) => { const [selectedKeys, setSelectedKeys] = useState(['1', '2']); - const onSelectionChange = (key) => { - setSelectedKeys(key); + const onSelectionChange = (keys) => { + setSelectedKeys(keys); }; return MenuTemplate({ @@ -539,8 +539,8 @@ MenuSelectableMultiple.play = async ({ canvasElement }) => { export const MenuSelectableCheckboxes = (props) => { const [selectedKeys, setSelectedKeys] = useState(['1', '2']); - const onSelectionChange = (key) => { - setSelectedKeys(key); + const onSelectionChange = (keys) => { + setSelectedKeys(keys); }; return MenuTemplate({ @@ -554,8 +554,8 @@ export const MenuSelectableCheckboxes = (props) => { export const MenuSelectableRadio = (props) => { const [selectedKeys, setSelectedKeys] = useState(['1']); - const onSelectionChange = (key) => { - setSelectedKeys(key); + const onSelectionChange = (keys) => { + setSelectedKeys(keys); }; return MenuTemplate({ @@ -590,8 +590,8 @@ export const PaymentDetails = (props) => { export const ItemCustomIcons = (props) => { const [selectedKeys, setSelectedKeys] = useState(['1']); - const onSelectionChange = (key) => { - setSelectedKeys(key); + const onSelectionChange = (keys) => { + setSelectedKeys(keys); }; return ( @@ -624,8 +624,8 @@ export const ItemCustomIcons = (props) => { export const ItemWithTooltip = (props) => { const [selectedKeys, setSelectedKeys] = useState(['1']); - const onSelectionChange = (key) => { - setSelectedKeys(key); + const onSelectionChange = (keys) => { + setSelectedKeys(keys); }; return ( @@ -671,8 +671,8 @@ export const ItemWithTooltip = (props) => { export const SectionsWithTooltips = (props) => { const [selectedKeys, setSelectedKeys] = useState(['copy']); - const onSelectionChange = (key) => { - setSelectedKeys(key); + const onSelectionChange = (keys) => { + setSelectedKeys(keys); }; return ( diff --git a/src/components/actions/Menu/Menu.test.tsx b/src/components/actions/Menu/Menu.test.tsx index 0ba044617..a652df654 100644 --- a/src/components/actions/Menu/Menu.test.tsx +++ b/src/components/actions/Menu/Menu.test.tsx @@ -70,8 +70,7 @@ describe('', () => { expect(onSelectionChange).toHaveBeenCalledTimes(1); const selectionArg = onSelectionChange.mock.calls[0][0]; - expect(selectionArg).toBeInstanceOf(Set); - expect(selectionArg.has('copy')).toBe(true); + expect(selectionArg).toEqual(['copy']); }); it('should work with multiple selection mode', async () => { @@ -97,8 +96,7 @@ describe('', () => { expect(onSelectionChange).toHaveBeenCalledTimes(1); const firstCall = onSelectionChange.mock.calls[0][0]; - expect(firstCall).toBeInstanceOf(Set); - expect(firstCall.has('copy')).toBe(true); + expect(firstCall).toEqual(['copy']); await act(async () => { await userEvent.click(pasteItem); @@ -106,9 +104,7 @@ describe('', () => { expect(onSelectionChange).toHaveBeenCalledTimes(2); const secondCall = onSelectionChange.mock.calls[1][0]; - expect(secondCall).toBeInstanceOf(Set); - expect(secondCall.has('copy')).toBe(true); - expect(secondCall.has('paste')).toBe(true); + expect(secondCall).toEqual(expect.arrayContaining(['copy', 'paste'])); }); // Controlled and uncontrolled state tests @@ -648,8 +644,7 @@ describe('', () => { expect(onSelectionChange).toHaveBeenCalledTimes(1); const selectionArg = onSelectionChange.mock.calls[0][0]; - expect(selectionArg).toBeInstanceOf(Set); - expect(selectionArg.has('copy')).toBe(true); + expect(selectionArg).toEqual(['copy']); expect(onAction).toHaveBeenCalledWith('copy'); }); @@ -712,8 +707,7 @@ describe('', () => { expect(onSelectionChange).toHaveBeenCalledTimes(1); const selectionArg = onSelectionChange.mock.calls[0][0]; - expect(selectionArg).toBeInstanceOf(Set); - expect(selectionArg.has('paste')).toBe(true); + expect(selectionArg).toEqual(['paste']); // Simulate controlled state update rerender( @@ -754,9 +748,7 @@ describe('', () => { expect(onSelectionChange).toHaveBeenCalledTimes(1); const selectionArg = onSelectionChange.mock.calls[0][0]; - expect(selectionArg).toBeInstanceOf(Set); - expect(selectionArg.has('copy')).toBe(true); - expect(selectionArg.has('paste')).toBe(true); + expect(selectionArg).toEqual(expect.arrayContaining(['copy', 'paste'])); // Simulate controlled state update rerender( @@ -912,9 +904,7 @@ describe('', () => { expect(onSelectionChange).toHaveBeenCalledTimes(1); const selectionArg = onSelectionChange.mock.calls[0][0]; - expect(selectionArg).toBeInstanceOf(Set); - expect(selectionArg.has('copy')).toBe(false); - expect(selectionArg.has('paste')).toBe(true); + expect(selectionArg).toEqual(['paste']); // 'copy' was deselected }); // Test with width and height props diff --git a/src/components/actions/Menu/Menu.tsx b/src/components/actions/Menu/Menu.tsx index 59cabca80..c08920a96 100644 --- a/src/components/actions/Menu/Menu.tsx +++ b/src/components/actions/Menu/Menu.tsx @@ -32,7 +32,10 @@ import { StyledDivider, StyledHeader, StyledMenu } from './styled'; export interface CubeMenuProps extends BasePropsWithoutChildren, ContainerStyleProps, - AriaMenuProps { + Omit< + AriaMenuProps, + 'selectedKeys' | 'defaultSelectedKeys' | 'onSelectionChange' + > { selectionIcon?: MenuSelectionType; // @deprecated header?: ReactNode; @@ -43,19 +46,26 @@ export interface CubeMenuProps sectionHeadingStyles?: Styles; /** * Whether keyboard navigation should wrap around when reaching the start/end of the collection. - * This directly maps to the `shouldFocusWrap` option supported by React-Aria’s `useMenu` hook. + * This directly maps to the `shouldFocusWrap` option supported by React-Aria's `useMenu` hook. */ shouldFocusWrap?: boolean; /** * Whether the menu should automatically receive focus when it mounts. - * This directly maps to the `autoFocus` option supported by React-Aria’s `useMenu` hook. + * This directly maps to the `autoFocus` option supported by React-Aria's `useMenu` hook. */ autoFocus?: boolean | FocusStrategy; shouldUseVirtualFocus?: boolean; /** Size of the menu items */ 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 Menu( @@ -71,11 +81,40 @@ function Menu( selectionIcon, size = 'small', qa, + selectedKeys, + defaultSelectedKeys, + onSelectionChange, ...rest } = props; const domRef = useDOMRef(ref); const contextProps = useMenuContext(); - const completeProps = mergeProps(contextProps, rest); + + // Convert string[] to Set for React Aria compatibility + const ariaSelectedKeys = selectedKeys ? new Set(selectedKeys) : undefined; + const ariaDefaultSelectedKeys = defaultSelectedKeys + ? new Set(defaultSelectedKeys) + : undefined; + + // Convert Set to string[] for the callback + const handleSelectionChange = onSelectionChange + ? (keys: any) => { + if (keys === 'all') { + // Handle 'all' selection case - for now, we'll pass an empty array + // This is a rare edge case that might need special handling + onSelectionChange([]); + } else if (keys instanceof Set) { + onSelectionChange(Array.from(keys).map((key) => String(key))); + } else { + onSelectionChange([]); + } + } + : undefined; + + const completeProps = mergeProps(contextProps, rest, { + selectedKeys: ariaSelectedKeys, + defaultSelectedKeys: ariaDefaultSelectedKeys, + onSelectionChange: handleSelectionChange, + }); // Props used for collection building. const treeProps = completeProps as typeof completeProps; diff --git a/src/components/actions/Menu/styled.tsx b/src/components/actions/Menu/styled.tsx index 0fc04d8b3..73513eda6 100644 --- a/src/components/actions/Menu/styled.tsx +++ b/src/components/actions/Menu/styled.tsx @@ -87,8 +87,8 @@ export const StyledItem = tasty({ ...DEFAULT_NEUTRAL_STYLES, // Override specifics for menu context - display: 'grid', - flow: 'column', + display: 'flex', + flow: 'row', justifyContent: 'stretch', listStyle: 'none', height: { From 547f76be4bf830f6cfd8ccf86f0023846002c663 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Fri, 11 Jul 2025 17:22:15 +0200 Subject: [PATCH 6/8] feat(CommandPalette): add new component * 6 --- .../actions/CommandMenu/CommandMenu.docs.mdx | 17 --- .../CommandMenu/CommandMenu.stories.tsx | 123 +++--------------- .../actions/CommandMenu/CommandMenu.tsx | 6 - 3 files changed, 20 insertions(+), 126 deletions(-) diff --git a/src/components/actions/CommandMenu/CommandMenu.docs.mdx b/src/components/actions/CommandMenu/CommandMenu.docs.mdx index a6390494b..ceb6c5f32 100644 --- a/src/components/actions/CommandMenu/CommandMenu.docs.mdx +++ b/src/components/actions/CommandMenu/CommandMenu.docs.mdx @@ -312,23 +312,6 @@ The CommandMenu supports enhanced search capabilities: - **Force mount**: Certain items can always be visible regardless of search filter - **Custom filtering**: Override the default search algorithm with custom logic -### Integration with Dialog System - -```jsx -import { useDialogContainer } from '../../../hooks'; - -const CommandMenuDialog = useDialogContainer(CommandMenu); - -// Usage - setIsOpen(false)} - searchPlaceholder="Search commands..." -> - Action 1 - -``` - ## Best Practices ### Do's diff --git a/src/components/actions/CommandMenu/CommandMenu.stories.tsx b/src/components/actions/CommandMenu/CommandMenu.stories.tsx index f54ccf89a..8ca5d2335 100644 --- a/src/components/actions/CommandMenu/CommandMenu.stories.tsx +++ b/src/components/actions/CommandMenu/CommandMenu.stories.tsx @@ -10,6 +10,7 @@ import { IconFileText, IconFolder, IconSearch, + IconSelect, IconSettings, } from '@tabler/icons-react'; import React, { useState } from 'react'; @@ -135,36 +136,42 @@ const basicCommands = [ 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: , }, ]; @@ -291,6 +298,7 @@ export const Default: StoryFn> = (args) => ( key={command.key} description={command.description} hotkeys={command.hotkeys} + icon={command.icon} > {command.label} @@ -349,6 +357,7 @@ export const WithMenuTrigger: StoryFn> = (args) => ( key={command.key} description={command.description} hotkeys={command.hotkeys} + icon={command.icon} > {command.label} @@ -411,6 +420,7 @@ export const ControlledSearch: StoryFn> = (args) => { key={command.key} description={command.description} hotkeys={command.hotkeys} + icon={command.icon} > {command.label} @@ -432,6 +442,7 @@ export const LoadingState: StoryFn> = (args) => ( key={command.key} description={command.description} hotkeys={command.hotkeys} + icon={command.icon} > {command.label} @@ -469,6 +480,7 @@ export const CustomFilter: StoryFn> = (args) => ( key={command.key} description={command.description} hotkeys={command.hotkeys} + icon={command.icon} > {command.label} @@ -516,6 +528,7 @@ export const ForceMountItems: StoryFn> = (args) => ( key={command.key} description={command.description} hotkeys={command.hotkeys} + icon={command.icon} > {command.label} @@ -560,6 +573,7 @@ export const MultipleSelection: StoryFn> = (args) => { key={command.key} description={command.description} hotkeys={command.hotkeys} + icon={command.icon} > {command.label} @@ -595,6 +609,7 @@ export const SingleSelection: StoryFn> = (args) => { key={command.key} description={command.description} hotkeys={command.hotkeys} + icon={command.icon} > {command.label} @@ -627,6 +642,7 @@ export const CustomStyling: StoryFn> = (args) => ( key={command.key} description={command.description} hotkeys={command.hotkeys} + icon={command.icon} > {command.label} @@ -688,6 +704,7 @@ export const HotkeyTesting: StoryFn> = (args) => { key={command.key} description={command.description} hotkeys={command.hotkeys} + icon={command.icon} > {command.label} @@ -709,6 +726,7 @@ export const MediumSize: StoryFn> = (args) => ( key={command.key} description={command.description} hotkeys={command.hotkeys} + icon={command.icon} > {command.label} @@ -731,6 +749,7 @@ export const WithDialog: StoryFn> = (args) => ( key={command.key} description={command.description} hotkeys={command.hotkeys} + icon={command.icon} > {command.label} @@ -781,6 +800,7 @@ function CommandMenuDialogContent({ key={command.key} description={command.description} hotkeys={command.hotkeys} + icon={command.icon} > {command.label} @@ -825,106 +845,3 @@ WithDialogContainer.play = async ({ canvasElement }) => { canvas.getByPlaceholderText('Search commands...'); }); }; - -export const WithIcons: StoryFn> = (args) => ( - - - } - description="Create a new file" - hotkeys="Ctrl+N" - > - New File - - } - description="Open an existing file" - hotkeys="Ctrl+O" - > - Open File - - } - description="Save current file" - hotkeys="Ctrl+S" - > - Save File - - - - - } - description="Copy selected text" - hotkeys="Ctrl+C" - keywords={['duplicate', 'clone']} - > - Copy - - } - description="Paste from clipboard" - hotkeys="Ctrl+V" - keywords={['insert']} - > - Paste - - } - description="Cut selected text" - hotkeys="Ctrl+X" - > - Cut - - } - description="Undo last action" - hotkeys="Ctrl+Z" - > - Undo - - } - description="Redo last action" - hotkeys="Ctrl+Y" - > - Redo - - - - - } - description="Search in files" - hotkeys="Ctrl+F" - > - Search - - } - description="Open settings" - hotkeys="Ctrl+." - > - Settings - - - Documents - - - -); - -WithIcons.args = { - searchPlaceholder: 'Search commands with icons...', - autoFocus: true, -}; diff --git a/src/components/actions/CommandMenu/CommandMenu.tsx b/src/components/actions/CommandMenu/CommandMenu.tsx index 5e02067ff..a7f60b9b6 100644 --- a/src/components/actions/CommandMenu/CommandMenu.tsx +++ b/src/components/actions/CommandMenu/CommandMenu.tsx @@ -46,7 +46,6 @@ export interface CommandMenuItem { // Enhanced search features keywords?: string[]; - value?: string; forceMount?: boolean; // Standard Menu item props inherited @@ -186,11 +185,6 @@ function CommandMenuBase( ); } - // Check custom value if available - if (item?.value && typeof item.value === 'string') { - return textFilterFn(item.value, inputValue); - } - return false; }, [textFilterFn, shouldFilter], From 031a11f8b4311a7994ce9a4570cd5f0bd77050c5 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Fri, 11 Jul 2025 17:33:58 +0200 Subject: [PATCH 7/8] feat(CommandPalette): add new component * 7 --- .../actions/CommandMenu/CommandMenu.tsx | 17 ++++++++++++----- src/components/actions/Menu/Menu.tsx | 13 +++++++------ 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/components/actions/CommandMenu/CommandMenu.tsx b/src/components/actions/CommandMenu/CommandMenu.tsx index a7f60b9b6..200b41728 100644 --- a/src/components/actions/CommandMenu/CommandMenu.tsx +++ b/src/components/actions/CommandMenu/CommandMenu.tsx @@ -118,13 +118,14 @@ function CommandMenuBase( ? new Set(defaultSelectedKeys) : undefined; - // Convert Set to string[] for the callback const handleSelectionChange = onSelectionChange ? (keys: any) => { if (keys === 'all') { - // Handle 'all' selection case - for now, we'll pass an empty array - // This is a rare edge case that might need special handling - onSelectionChange([]); + // 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 { @@ -239,6 +240,7 @@ function CommandMenuBase( }; const treeState = useTreeState(treeStateProps); + const collectionItems = [...treeState.collection]; const hasSections = collectionItems.some((item) => item.type === 'section'); @@ -304,7 +306,12 @@ function CommandMenuBase( // Use menu hook for accessibility const { menuProps } = useMenu( - { ...treeStateProps, 'aria-label': 'Command palette menu' }, + { + ...completeProps, + 'aria-label': 'Command palette menu', + filter: collectionFilter, + shouldUseVirtualFocus: true, + }, treeState, menuRef, ); diff --git a/src/components/actions/Menu/Menu.tsx b/src/components/actions/Menu/Menu.tsx index c08920a96..b223e1524 100644 --- a/src/components/actions/Menu/Menu.tsx +++ b/src/components/actions/Menu/Menu.tsx @@ -1,7 +1,7 @@ import { useSyncRef } from '@react-aria/utils'; import { useDOMRef } from '@react-spectrum/utils'; import { DOMRef, FocusStrategy, ItemProps, Key } from '@react-types/shared'; -import React, { ReactElement, ReactNode, useMemo } from 'react'; +import React, { ReactElement, ReactNode, useMemo, useRef } from 'react'; import { AriaMenuProps, useMenu } from 'react-aria'; import { Item as BaseItem, @@ -95,13 +95,14 @@ function Menu( ? new Set(defaultSelectedKeys) : undefined; - // Convert Set to string[] for the callback const handleSelectionChange = onSelectionChange ? (keys: any) => { if (keys === 'all') { - // Handle 'all' selection case - for now, we'll pass an empty array - // This is a rare edge case that might need special handling - onSelectionChange([]); + // Handle 'all' selection case - collect all available keys + const allKeys = Array.from(state.collection.getKeys()).map( + (key: any) => String(key), + ); + onSelectionChange(allKeys); } else if (keys instanceof Set) { onSelectionChange(Array.from(keys).map((key) => String(key))); } else { @@ -123,7 +124,7 @@ function Menu( const collectionItems = [...state.collection]; const hasSections = collectionItems.some((item) => item.type === 'section'); - const { menuProps } = useMenu(treeProps, state, domRef); + const { menuProps } = useMenu(completeProps, state, domRef); const styles = useMemo( () => extractStyles(completeProps, CONTAINER_STYLES), [completeProps], From aca5a85f9cda1cae7de6f127525ad5ac9351cbfa Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Fri, 11 Jul 2025 17:45:57 +0200 Subject: [PATCH 8/8] feat(CommandPalette): add new component * 8 --- src/components/actions/CommandMenu/CommandMenu.stories.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/actions/CommandMenu/CommandMenu.stories.tsx b/src/components/actions/CommandMenu/CommandMenu.stories.tsx index 8ca5d2335..ed1e5c968 100644 --- a/src/components/actions/CommandMenu/CommandMenu.stories.tsx +++ b/src/components/actions/CommandMenu/CommandMenu.stories.tsx @@ -555,7 +555,7 @@ EmptyState.args = { }; export const MultipleSelection: StoryFn> = (args) => { - const [selectedKeys, setSelectedKeys] = useState([]); + const [selectedKeys, setSelectedKeys] = useState(['copy', 'cut']); return (
@@ -565,6 +565,7 @@ export const MultipleSelection: StoryFn> = (args) => {