Skip to content

feat: radial menu#4223

Open
peterchinman wants to merge 3 commits into
mainfrom
peter/radial-menu
Open

feat: radial menu#4223
peterchinman wants to merge 3 commits into
mainfrom
peter/radial-menu

Conversation

@peterchinman

Copy link
Copy Markdown
Contributor

No description provided.

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 73f177ac-c476-4447-ba3b-d2192fa20124

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This pull request introduces a complete radial menu system. A new geometry.ts module provides compass-bearing math, slot/ring/aim computation, SVG annular-sector path generation, and viewport clamping utilities, covered by a new geometry.test.ts test suite. The RadialMenu SolidJS component renders a Portal overlay, tracks pointer position and viewport size, derives the active item from pointer aim, and handles hold and toggle interaction modes via global window event listeners. Public contracts (RadialMenuMode, RadialMenuItem, RadialMenuProps) are defined in types.ts and re-exported through barrel files. A new useRadialMenu hook wires the menu into the app's hotkey scope system, managing a dedicated command scope for item shortcuts and an optional trigger hotkey. A RadialMenuDemo component demonstrates all eight slices with hotkeys and is registered as a LOCAL_ONLY route named radial-menu-demo.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Description check ❓ Inconclusive No description was provided by the author, making it impossible to verify relevance to the changeset. Add a pull request description explaining the purpose, implementation details, and any relevant context for the radial menu feature.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: radial menu' follows conventional commits format with proper 'feat:' prefix and is 17 characters, well under the 72-character limit.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (1)
js/app/packages/ui/components/RadialMenu/RadialMenu.tsx (1)

135-136: ⚡ Quick win

Replace nested ternaries with if/else helpers.

This block uses nested ternaries in two places, which violates the TS/TSX project rule and makes branching harder to scan.

Guideline-compliant rewrite
   const transformFor = (align: LabelAlign): string => {
-    const x = align === 'center' ? '-50%' : align === 'west' ? '-100%' : '0';
+    let x = '0';
+    if (align === 'center') x = '-50%';
+    else if (align === 'west') x = '-100%';
     return `translate(${x}, -50%)`;
   };
+
+  const itemStateClass = (disabled: boolean, active: boolean): string => {
+    if (disabled) return 'border-edge-muted text-ink-disabled';
+    if (active) return 'border-accent text-accent ring-1 ring-accent';
+    return 'border-edge text-ink';
+  };
@@
-                    cell.item.disabled
-                      ? 'border-edge-muted text-ink-disabled'
-                      : isActive()
-                        ? 'border-accent text-accent ring-1 ring-accent'
-                        : 'border-edge text-ink'
+                    itemStateClass(cell.item.disabled ?? false, isActive())
As per coding guidelines: `**/*.{js,jsx,ts,tsx}`: “Avoid nested ternary operators - prefer switch statements or if/else chains for multiple conditions”.

Also applies to: 258-263

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/app/packages/ui/components/RadialMenu/RadialMenu.tsx` around lines 135 -
136, Replace the nested ternary operators used for calculating the x translation
value with an if/else chain or switch statement. In the section where x is
assigned based on the align parameter (checking if align equals 'center',
'west', or defaults to '0'), extract this logic into a helper function or use an
if/else block to determine the correct x value before returning the translate
string. Apply the same refactoring pattern to the similar nested ternary
condition found in the second location (around lines 258-263) to maintain
consistency and comply with the project's coding guidelines against nested
ternary operators.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@js/app/packages/app/component/useRadialMenu.ts`:
- Line 51: In the `onCleanup` callback of `useRadialMenu.ts`, before calling
`removeScope(commandScopeId)`, check if the currently active scope (obtained
from `activeScope()`) matches the `commandScopeId` being removed. If it does,
restore the active scope to a DOM scope (typically by activating the parent
scope or a default scope) before removing the command scope to prevent leaving
hotkeys in a stale state after cleanup.

In `@js/app/packages/ui/components/RadialMenu/RadialMenu.tsx`:
- Around line 205-210: The click event listener for the RadialMenu is registered
in the bubble phase (default), which allows clicks to reach the underlying UI
before the menu can intercept them. This causes clicks to potentially trigger
actions in the UI behind the menu. Fix this by registering the click event
listener in the capture phase instead of the bubble phase, so that the menu can
intercept and prevent the click from reaching underlying elements. Change the
addEventListener call for 'click' on window (line 210) to use the capture phase
by passing true as the third argument to addEventListener or using an options
object with capture: true.

In `@js/app/packages/ui/components/RadialMenu/types.ts`:
- Around line 23-27: The slots property in the RadialMenu types file is
currently typed as Direction[], which allows an empty array that cannot be
selected since downstream code uses some(...) to check slots. Change the slots
property type from Direction[] to a non-empty array type (such as [Direction,
...Direction[]]) to ensure at least one direction is always specified and
prevent invalid menu items that cannot be aimed or selected.
- Around line 65-70: The JSDoc comments documenting default values for
deadZoneRadius, ringThickness, and ringGap properties in the type definitions do
not match the actual default values implemented in the RadialMenu component.
Update the documented defaults in the comments to align with the actual
implementation values: change deadZoneRadius default from 32 to 40,
ringThickness default from 56 to 96, and ringGap default from 6 to 8 to ensure
the public contract accurately reflects how the component behaves.

---

Nitpick comments:
In `@js/app/packages/ui/components/RadialMenu/RadialMenu.tsx`:
- Around line 135-136: Replace the nested ternary operators used for calculating
the x translation value with an if/else chain or switch statement. In the
section where x is assigned based on the align parameter (checking if align
equals 'center', 'west', or defaults to '0'), extract this logic into a helper
function or use an if/else block to determine the correct x value before
returning the translate string. Apply the same refactoring pattern to the
similar nested ternary condition found in the second location (around lines
258-263) to maintain consistency and comply with the project's coding guidelines
against nested ternary operators.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b7dbe16d-024f-4884-8fab-63995fe4207e

📥 Commits

Reviewing files that changed from the base of the PR and between 1029ef6 and 7b5a6c0.

📒 Files selected for processing (10)
  • js/app/packages/app/component/RadialMenuDemo.tsx
  • js/app/packages/app/component/split-layout/componentRegistry.tsx
  • js/app/packages/app/component/useRadialMenu.ts
  • js/app/packages/ui/components/RadialMenu/RadialMenu.tsx
  • js/app/packages/ui/components/RadialMenu/geometry.test.ts
  • js/app/packages/ui/components/RadialMenu/geometry.ts
  • js/app/packages/ui/components/RadialMenu/index.ts
  • js/app/packages/ui/components/RadialMenu/types.ts
  • js/app/packages/ui/index.ts
  • js/app/vitest.config.ts

type: 'command',
activationKeys: config.triggerHotkey ? [config.triggerHotkey] : [],
});
onCleanup(() => removeScope(commandScopeId));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restore active scope before removing the command scope.

If unmount happens while activeScope() is this command scope, removeScope(commandScopeId) deletes it without reactivating a DOM scope (command-scope removal does not do that in @core/hotkey/utils). That can leave hotkeys in a broken/stale active-scope state after teardown.

Suggested fix
-  onCleanup(() => removeScope(commandScopeId));
+  onCleanup(() => {
+    if (activeScope() === commandScopeId) {
+      activateClosestDOMScope();
+    }
+    removeScope(commandScopeId);
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/app/packages/app/component/useRadialMenu.ts` at line 51, In the
`onCleanup` callback of `useRadialMenu.ts`, before calling
`removeScope(commandScopeId)`, check if the currently active scope (obtained
from `activeScope()`) matches the `commandScopeId` being removed. If it does,
restore the active scope to a DOM scope (typically by activating the parent
scope or a default scope) before removing the command scope to prevent leaving
hotkeys in a stale state after cleanup.

Comment on lines +205 to +210
window.addEventListener('pointermove', onMove);
window.addEventListener('resize', onResize);
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onRelease);
window.addEventListener('pointerup', onRelease);
window.addEventListener('click', onClick);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent click-through to underlying UI while the menu is open.

Line 230 makes the overlay non-interactive (pointer-events-none), and Line 210 handles click on window in bubble phase. The underlying target processes the click first, so committing a radial selection can also trigger whatever is behind it.

Proposed interception in capture phase
-        const onClick = () => {
-          if (effectiveMode() !== 'hold' && armed) commitAim();
+        const onClick = (e: MouseEvent) => {
+          if (!armed) return;
+          e.preventDefault();
+          e.stopPropagation();
+          if (effectiveMode() !== 'hold') commitAim();
         };
@@
-        window.addEventListener('click', onClick);
+        window.addEventListener('click', onClick, true);
@@
-          window.removeEventListener('click', onClick);
+          window.removeEventListener('click', onClick, true);

Also applies to: 228-233

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/app/packages/ui/components/RadialMenu/RadialMenu.tsx` around lines 205 -
210, The click event listener for the RadialMenu is registered in the bubble
phase (default), which allows clicks to reach the underlying UI before the menu
can intercept them. This causes clicks to potentially trigger actions in the UI
behind the menu. Fix this by registering the click event listener in the capture
phase instead of the bubble phase, so that the menu can intercept and prevent
the click from reaching underlying elements. Change the addEventListener call
for 'click' on window (line 210) to use the capture phase by passing true as the
third argument to addEventListener or using an options object with capture:
true.

Comment on lines +23 to +27
* Contiguous slots this item occupies. A single direction (`['N']`) or an arc
* built with `span('N', 'W')`. Slots may be listed in any rotational order.
*/
slots: Direction[];
/** Which ring the item lives on. Defaults to `'inner'`. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Make slots non-empty at the type level.

Line 26 currently allows [], which produces an item that can render but can never be aimed/selected because downstream lookup uses some(...) over slots.

Proposed type tightening
-  slots: Direction[];
+  slots: [Direction, ...Direction[]];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* Contiguous slots this item occupies. A single direction (`['N']`) or an arc
* built with `span('N', 'W')`. Slots may be listed in any rotational order.
*/
slots: Direction[];
/** Which ring the item lives on. Defaults to `'inner'`. */
* Contiguous slots this item occupies. A single direction (`['N']`) or an arc
* built with `span('N', 'W')`. Slots may be listed in any rotational order.
*/
slots: [Direction, ...Direction[]];
/** Which ring the item lives on. Defaults to `'inner'`. */
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/app/packages/ui/components/RadialMenu/types.ts` around lines 23 - 27, The
slots property in the RadialMenu types file is currently typed as Direction[],
which allows an empty array that cannot be selected since downstream code uses
some(...) to check slots. Change the slots property type from Direction[] to a
non-empty array type (such as [Direction, ...Direction[]]) to ensure at least
one direction is always specified and prevent invalid menu items that cannot be
aimed or selected.

Comment on lines +65 to +70
/** Dead-zone radius in px. Defaults to 32. */
deadZoneRadius?: number;
/** Radial thickness of each ring band in px. Defaults to 56. */
ringThickness?: number;
/** Gap between the dead zone and rings in px. Defaults to 6. */
ringGap?: number;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Align documented defaults with actual component defaults.

Line 65-70 documents defaults 32 / 56 / 6, but js/app/packages/ui/components/RadialMenu/RadialMenu.tsx (Line 25-27) uses 40 / 96 / 8. This public contract mismatch can mislead consumers configuring layout behavior.
[suggested fix: make docs and implementation match in one direction before merge.]

Proposed doc-side alignment (if runtime defaults are intentional)
-  /** Dead-zone radius in px. Defaults to 32. */
+  /** Dead-zone radius in px. Defaults to 40. */
   deadZoneRadius?: number;
-  /** Radial thickness of each ring band in px. Defaults to 56. */
+  /** Radial thickness of each ring band in px. Defaults to 96. */
   ringThickness?: number;
-  /** Gap between the dead zone and rings in px. Defaults to 6. */
+  /** Gap between the dead zone and rings in px. Defaults to 8. */
   ringGap?: number;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/** Dead-zone radius in px. Defaults to 32. */
deadZoneRadius?: number;
/** Radial thickness of each ring band in px. Defaults to 56. */
ringThickness?: number;
/** Gap between the dead zone and rings in px. Defaults to 6. */
ringGap?: number;
/** Dead-zone radius in px. Defaults to 40. */
deadZoneRadius?: number;
/** Radial thickness of each ring band in px. Defaults to 96. */
ringThickness?: number;
/** Gap between the dead zone and rings in px. Defaults to 8. */
ringGap?: number;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/app/packages/ui/components/RadialMenu/types.ts` around lines 65 - 70, The
JSDoc comments documenting default values for deadZoneRadius, ringThickness, and
ringGap properties in the type definitions do not match the actual default
values implemented in the RadialMenu component. Update the documented defaults
in the comments to align with the actual implementation values: change
deadZoneRadius default from 32 to 40, ringThickness default from 56 to 96, and
ringGap default from 6 to 8 to ensure the public contract accurately reflects
how the component behaves.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant