Skip to content

feat(CommandMenu): sort menu items on top when opened #724

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

tenphi
Copy link
Member

@tenphi tenphi commented Jul 15, 2025

No description provided.

Copy link

vercel bot commented Jul 15, 2025

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
cube-ui-kit ✅ Ready (Inspect) Visit Preview 💬 Add feedback Jul 15, 2025 3:00pm
cube-ui-kit-cursor ✅ Ready (Inspect) Visit Preview 💬 Add feedback Jul 15, 2025 3:00pm

Copy link

changeset-bot bot commented Jul 15, 2025

🦋 Changeset detected

Latest commit: 6d0ff42

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cube-dev/ui-kit Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link
Contributor

📦 NPM canary release

Deployed canary version 0.0.0-canary-1b4243f.

Copy link
Contributor

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR adds functionality to sort selected menu items to the top when the CommandMenu is opened in multiple selection mode. The implementation prevents content shifting during user interaction by capturing the initial selected state and using it consistently for sorting.

Key changes:

  • Added initial selected keys tracking to maintain stable sorting during user interaction
  • Implemented sorting logic that places selected items first in both filtered and unfiltered states
  • Added comprehensive test coverage for the new sorting behavior

Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
CommandMenu.tsx Core implementation of selected items sorting logic with initial state tracking
CommandMenu.test.tsx Comprehensive test suite covering sorting behavior in various scenarios
CommandMenu.stories.tsx Updated story to demonstrate the new sorting feature
new-snails-raise.md Changeset documentation for the feature

initialSelectedKeysRef.current = undefined;
}
}
}, [selectedKeys, defaultSelectedKeys]); // Depend on both props for initial setup
Copy link
Preview

Copilot AI Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useMemo dependency array includes selectedKeys and defaultSelectedKeys but the code inside references ariaSelectedKeys and ariaDefaultSelectedKeys. This mismatch could cause the memo to not update when the actual variables change, or update unnecessarily when unrelated variables change.

Suggested change
}, [selectedKeys, defaultSelectedKeys]); // Depend on both props for initial setup
}, [ariaSelectedKeys, ariaDefaultSelectedKeys]); // Depend on both props for initial setup

Copilot uses AI. Check for mistakes.


// Initialize initial selected keys only once on mount
// This captures the initial state and prevents sorting changes during user interaction
React.useMemo(() => {
Copy link
Preview

Copilot AI Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using useMemo for side effects (setting refs) is an anti-pattern. Consider using useEffect instead, as useMemo should only be used for expensive computations that return a value.

Suggested change
React.useMemo(() => {
React.useEffect(() => {

Copilot uses AI. Check for mistakes.

Comment on lines +384 to +423
const sections: any[] = [];
const items: any[] = [];

[...items].forEach((item) => {
if (item.type === 'section') {
const filteredChildren = filterNodes(item.childNodes);
if (filteredChildren.length) {
result.push({
...item,
childNodes: filteredChildren,
nodeList.forEach((node) => {
if (node.type === 'section') {
// Process section children recursively
const sortedChildren = sortWithSelectedFirst(
Array.from(node.childNodes),
);
sections.push({
...node,
childNodes: sortedChildren,
});
} else {
items.push(node);
}
} else {
const text = item.textValue ?? String(item.rendered ?? '');
if (enhancedFilter(text, term, item.props)) {
result.push(item);
}
}
});
});

return result;
};
// Sort items and combine with sections
const sortedItems = sortWithSelectedFirst(items);
return [...sections, ...sortedItems];
};

return filterNodes(collectionItems);
}, [collectionItems, searchValue, enhancedFilter]);
resultItems = processNodes(collectionItems);
}

// Sort items at the root level with selected ones first (for items not in sections)
const sections: any[] = [];
const items: any[] = [];

resultItems.forEach((item) => {
if (item.type === 'section') {
sections.push(item);
} else {
items.push(item);
}
});

const sortedItems = sortWithSelectedFirst(items);
return [...sections, ...sortedItems];
Copy link
Preview

Copilot AI Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sorting logic is duplicated - the same separation of sections and items, followed by sorting, is performed both inside the processNodes function (lines 383-407) and again at the end (lines 410-423). This duplication could lead to maintenance issues and unnecessary processing.

Suggested change
const sections: any[] = [];
const items: any[] = [];
[...items].forEach((item) => {
if (item.type === 'section') {
const filteredChildren = filterNodes(item.childNodes);
if (filteredChildren.length) {
result.push({
...item,
childNodes: filteredChildren,
nodeList.forEach((node) => {
if (node.type === 'section') {
// Process section children recursively
const sortedChildren = sortWithSelectedFirst(
Array.from(node.childNodes),
);
sections.push({
...node,
childNodes: sortedChildren,
});
} else {
items.push(node);
}
} else {
const text = item.textValue ?? String(item.rendered ?? '');
if (enhancedFilter(text, term, item.props)) {
result.push(item);
}
}
});
});
return result;
};
// Sort items and combine with sections
const sortedItems = sortWithSelectedFirst(items);
return [...sections, ...sortedItems];
};
return filterNodes(collectionItems);
}, [collectionItems, searchValue, enhancedFilter]);
resultItems = processNodes(collectionItems);
}
// Sort items at the root level with selected ones first (for items not in sections)
const sections: any[] = [];
const items: any[] = [];
resultItems.forEach((item) => {
if (item.type === 'section') {
sections.push(item);
} else {
items.push(item);
}
});
const sortedItems = sortWithSelectedFirst(items);
return [...sections, ...sortedItems];
return sortSectionsAndItems(nodeList, sortWithSelectedFirst);
};
resultItems = processNodes(collectionItems);
}
// Sort items at the root level with selected ones first (for items not in sections)
return sortSectionsAndItems(resultItems, sortWithSelectedFirst);

Copilot uses AI. Check for mistakes.

const unselectedItems: any[] = [];

items.forEach((item) => {
if (initialSelectedKeysRef.current!.has(item.key)) {
Copy link
Preview

Copilot AI Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using non-null assertion operator (!) is risky here. Although there's a check above, it's safer to use optional chaining or a more explicit null check to prevent potential runtime errors.

Suggested change
if (initialSelectedKeysRef.current!.has(item.key)) {
if (initialSelectedKeysRef.current?.has(item.key)) {

Copilot uses AI. Check for mistakes.

Copy link
Contributor

🏋️ Size limit report

Name Size Passed?
All 261.71 KB (+0.09% 🔺) Yes 🎉
Tree shaking (just a Button) 21.71 KB (0% 🟰) Yes 🎉
Tree shaking (just an Icon) 11.4 KB (0% 🟰) Yes 🎉

Click here if you want to find out what is changed in this build

Copy link
Contributor

🧪 Storybook is successfully deployed!

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Incorrect Hook Usage Causes Stale Selection Data

The React.useMemo hook is misused for side effects by setting initialSelectedKeysRef. This should be useEffect. The useMemo also has an inconsistent dependency array [selectedKeys, defaultSelectedKeys] because its logic only runs once due to the hasInitializedRef.current guard. This prevents initialSelectedKeysRef from updating when selectedKeys or defaultSelectedKeys change, leading to stale selection data and incorrect sorting in multiple selection mode.

src/components/actions/CommandMenu/CommandMenu.tsx#L298-L311

// This captures the initial state and prevents sorting changes during user interaction
React.useMemo(() => {
// Only set initial keys once, on the first render
if (!hasInitializedRef.current) {
hasInitializedRef.current = true;
// Use selectedKeys if provided, otherwise fall back to defaultSelectedKeys
const initialKeys = ariaSelectedKeys || ariaDefaultSelectedKeys;
if (initialKeys !== undefined) {
initialSelectedKeysRef.current = new Set(initialKeys);
} else {
initialSelectedKeysRef.current = undefined;
}
}
}, [selectedKeys, defaultSelectedKeys]); // Depend on both props for initial setup

Fix in CursorFix in Web


Was this report helpful? Give feedback by reacting with 👍 or 👎

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant