Skip to content

Commit 26e73ec

Browse files
committed
feat(primitives)!: rewrite Context Menu on the owned Menu/Floating UI
1 parent c2b7baa commit 26e73ec

25 files changed

Lines changed: 616 additions & 657 deletions

apps/radix-docs/src/components/layouts/Announcement.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const { title, href } = Astro.props;
88
---
99

1010
<a
11-
href={href ? href : '/primitives/components/dropdown-menu'}
11+
href={href ? href : '/primitives/components/progress'}
1212
class="bg-muted group inline-flex items-center rounded-lg px-3 py-1 text-sm font-medium"
1313
>
1414
🎉{' '}

apps/radix-docs/src/components/layouts/DemoNoopContainer.astro

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,7 @@
11
---
22
import { DemoLoaderComponent } from '../demo-primitive-preview/demo-loader.component';
33
export interface Props {
4-
componentName?:
5-
| 'accordion'
6-
| 'avatar'
7-
| 'alert-dialog'
8-
| 'checkbox'
9-
| 'progress'
10-
| 'collapsible'
11-
| 'dropdown-menu'
12-
| 'default';
4+
componentName?: 'accordion' | 'avatar' | 'alert-dialog' | 'checkbox' | 'progress' | 'collapsible' | 'default';
135
componentFile?: string;
146
overflow?: boolean;
157
}

apps/radix-docs/src/components/layouts/DemosHomePage.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import DemoNoopContainer from '@/components/layouts/DemoNoopContainer.astro';
2121
<DemoNoopContainer componentName="progress" componentFile="progress-demo.tailwind" />
2222
</div>
2323
<div class="flex h-[240px] items-center justify-center rounded-lg border p-4">
24-
<DemoNoopContainer componentName="dropdown-menu" componentFile="dropdown-menu-demo.tailwind" />
24+
<DemoNoopContainer componentName="progress" componentFile="progress-demo.tailwind" />
2525
</div>
2626
</div>
2727
</div>

package.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"@angular-devkit/build-angular": "21.2.9",
7474
"@angular-devkit/core": "21.2.9",
7575
"@angular-devkit/schematics": "21.2.9",
76+
"@angular/build": "21.2.9",
7677
"@angular/cli": "21.2.12",
7778
"@angular/compiler-cli": "21.2.9",
7879
"@angular/language-service": "21.2.9",
@@ -87,7 +88,7 @@
8788
"@fontsource-variable/inter": "^5.2.8",
8889
"@fontsource-variable/outfit": "^5.2.8",
8990
"@fontsource/jetbrains-mono": "^5.2.8",
90-
"@angular/build": "21.2.9",
91+
"@lucide/angular": "^1.17.0",
9192
"@nx/angular": "22.7.5",
9293
"@nx/devkit": "22.7.5",
9394
"@nx/esbuild": "22.7.5",
@@ -114,11 +115,12 @@
114115
"@testing-library/angular": "^15.2.0",
115116
"@testing-library/jest-dom": "^6.6.3",
116117
"@testing-library/user-event": "^14.5.2",
117-
"@vitest/coverage-v8": "4.1.7",
118118
"@types/express": "4.17.21",
119119
"@types/jest-axe": "^3.5.9",
120120
"@types/node": "^22.19.19",
121121
"@typescript-eslint/utils": "^8.60.1",
122+
"@vitest/coverage-v8": "4.1.7",
123+
"@vitest/ui": "4.1.7",
122124
"angular-eslint": "^21.3.1",
123125
"autoprefixer": "^10.4.21",
124126
"chromatic": "^11.27.0",
@@ -132,10 +134,9 @@
132134
"globals": "^17.4.0",
133135
"husky": "^9.1.6",
134136
"jest-axe": "^10.0.0",
135-
"jsdom": "26.1.0",
136137
"jiti": "2.4.2",
138+
"jsdom": "26.1.0",
137139
"lint-staged": "^17.0.7",
138-
"@lucide/angular": "^1.17.0",
139140
"marked-highlight": "^2.2.1",
140141
"ng-packagr": "21.2.3",
141142
"nx": "22.7.5",
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { Component } from '@angular/core';
2+
import { ComponentFixture, TestBed } from '@angular/core/testing';
3+
import { RdxContextMenuModule } from '@radix-ng/primitives/context-menu';
4+
import { RdxMenuModule } from '@radix-ng/primitives/menu';
5+
6+
function rightClick(target: Element, clientX = 120, clientY = 80) {
7+
target.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX, clientY }));
8+
}
9+
10+
function pointerDown(target: Element) {
11+
target.dispatchEvent(new Event('pointerdown', { bubbles: true }));
12+
}
13+
14+
function keydown(target: Element, key: string) {
15+
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
16+
}
17+
18+
function flushRaf() {
19+
return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
20+
}
21+
22+
@Component({
23+
imports: [RdxContextMenuModule, RdxMenuModule],
24+
template: `
25+
<ng-container #root="rdxContextMenuRoot" rdxContextMenuRoot>
26+
<div [attr.data-disabled-trigger]="disabled" rdxContextMenuTrigger>Right click area</div>
27+
28+
@if (root.menuRoot.open()) {
29+
<div rdxMenuPositioner>
30+
<div rdxMenuPopup>
31+
<button rdxMenuItem>Back</button>
32+
<button rdxMenuItem>Reload</button>
33+
</div>
34+
</div>
35+
}
36+
</ng-container>
37+
`
38+
})
39+
class ContextMenuHost {
40+
disabled = false;
41+
}
42+
43+
@Component({
44+
imports: [RdxContextMenuModule, RdxMenuModule],
45+
template: `
46+
<ng-container #root="rdxContextMenuRoot" rdxContextMenuRoot>
47+
<div [disabled]="true" rdxContextMenuTrigger>Right click area</div>
48+
49+
@if (root.menuRoot.open()) {
50+
<div rdxMenuPositioner>
51+
<div rdxMenuPopup>
52+
<button rdxMenuItem>Back</button>
53+
</div>
54+
</div>
55+
}
56+
</ng-container>
57+
`
58+
})
59+
class DisabledContextMenuHost {}
60+
61+
describe('ContextMenu', () => {
62+
let fixture: ComponentFixture<ContextMenuHost>;
63+
let trigger: HTMLElement;
64+
65+
beforeEach(() => {
66+
TestBed.configureTestingModule({ imports: [ContextMenuHost] });
67+
fixture = TestBed.createComponent(ContextMenuHost);
68+
fixture.detectChanges();
69+
trigger = fixture.nativeElement.querySelector('[rdxContextMenuTrigger]');
70+
});
71+
72+
it('is closed by default', () => {
73+
expect(trigger.getAttribute('data-state')).toBe('closed');
74+
expect(fixture.nativeElement.querySelectorAll('[rdxMenuPopup]').length).toBe(0);
75+
});
76+
77+
it('opens the popup on right click', () => {
78+
rightClick(trigger);
79+
fixture.detectChanges();
80+
81+
expect(trigger.getAttribute('data-state')).toBe('open');
82+
expect(fixture.nativeElement.querySelectorAll('[rdxMenuPopup]').length).toBe(1);
83+
});
84+
85+
it('a pointer right-click focuses the popup without highlighting an item', async () => {
86+
// A pointerdown immediately precedes the contextmenu, marking it as pointer-initiated.
87+
pointerDown(trigger);
88+
rightClick(trigger);
89+
fixture.detectChanges();
90+
await flushRaf();
91+
fixture.detectChanges();
92+
93+
const popup: HTMLElement = fixture.nativeElement.querySelector('[rdxMenuPopup]');
94+
expect(document.activeElement).toBe(popup);
95+
expect(fixture.nativeElement.querySelector('[rdxMenuItem][data-highlighted]')).toBeNull();
96+
});
97+
98+
it('a keyboard context menu highlights the first item', async () => {
99+
// No preceding pointerdown — treated as keyboard-initiated.
100+
rightClick(trigger);
101+
fixture.detectChanges();
102+
await flushRaf();
103+
fixture.detectChanges();
104+
105+
const items: HTMLElement[] = Array.from(fixture.nativeElement.querySelectorAll('[rdxMenuItem]'));
106+
expect(document.activeElement).toBe(items[0]);
107+
expect(items[0].getAttribute('data-highlighted')).toBe('');
108+
});
109+
110+
it('prevents the native context menu', () => {
111+
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 });
112+
trigger.dispatchEvent(event);
113+
fixture.detectChanges();
114+
115+
expect(event.defaultPrevented).toBe(true);
116+
});
117+
118+
it('closes on Escape and stays closed', () => {
119+
rightClick(trigger);
120+
fixture.detectChanges();
121+
expect(trigger.getAttribute('data-state')).toBe('open');
122+
123+
const popup: HTMLElement = fixture.nativeElement.querySelector('[rdxMenuPopup]');
124+
keydown(popup, 'Escape');
125+
fixture.detectChanges();
126+
127+
expect(trigger.getAttribute('data-state')).toBe('closed');
128+
expect(fixture.nativeElement.querySelectorAll('[rdxMenuPopup]').length).toBe(0);
129+
});
130+
131+
it('does not open when the trigger is disabled', () => {
132+
TestBed.resetTestingModule();
133+
TestBed.configureTestingModule({ imports: [DisabledContextMenuHost] });
134+
const disabledFixture = TestBed.createComponent(DisabledContextMenuHost);
135+
disabledFixture.detectChanges();
136+
const disabledTrigger: HTMLElement = disabledFixture.nativeElement.querySelector('[rdxContextMenuTrigger]');
137+
138+
rightClick(disabledTrigger);
139+
disabledFixture.detectChanges();
140+
141+
expect(disabledTrigger.getAttribute('data-state')).toBe('closed');
142+
expect(disabledFixture.nativeElement.querySelectorAll('[rdxMenuPopup]').length).toBe(0);
143+
});
144+
});
Lines changed: 11 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,18 @@
11
import { NgModule } from '@angular/core';
2-
import { RdxContextMenuContentDirective } from './src/context-menu-content.directive';
3-
import { RdxContextMenuItemCheckboxDirective } from './src/context-menu-item-checkbox.directive';
4-
import { RdxContextMenuItemIndicatorDirective } from './src/context-menu-item-indicator.directive';
5-
import { RdxContextMenuItemRadioGroupDirective } from './src/context-menu-item-radio-group.directive';
6-
import { RdxContextMenuItemRadioDirective } from './src/context-menu-item-radio.directive';
7-
import { RdxContextMenuSelectable } from './src/context-menu-item-selectable';
8-
import { RdxContextMenuItemDirective } from './src/context-menu-item.directive';
9-
import { RdxContextMenuLabelDirective } from './src/context-menu-label.directive';
10-
import { RdxContextMenuSeparatorDirective } from './src/context-menu-separator.directive';
11-
import { RdxContextMenuTriggerDirective } from './src/context-menu-trigger.directive';
2+
import { RdxContextMenuRoot } from './src/context-menu-root';
3+
import { RdxContextMenuTrigger } from './src/context-menu-trigger';
124

13-
export * from './src/context-menu-content.directive';
14-
export * from './src/context-menu-item-checkbox.directive';
15-
export * from './src/context-menu-item-indicator.directive';
16-
export * from './src/context-menu-item-radio-group.directive';
17-
export * from './src/context-menu-item-radio.directive';
18-
export * from './src/context-menu-item-selectable';
19-
export * from './src/context-menu-item.directive';
20-
export * from './src/context-menu-label.directive';
21-
export * from './src/context-menu-separator.directive';
22-
export * from './src/context-menu-trigger.directive';
5+
export * from './src/context-menu-root';
6+
export * from './src/context-menu-trigger';
237

24-
const _imports = [
25-
RdxContextMenuContentDirective,
26-
RdxContextMenuSelectable,
27-
RdxContextMenuItemCheckboxDirective,
28-
RdxContextMenuItemDirective,
29-
RdxContextMenuItemRadioGroupDirective,
30-
RdxContextMenuItemIndicatorDirective,
31-
RdxContextMenuItemRadioDirective,
32-
RdxContextMenuLabelDirective,
33-
RdxContextMenuSeparatorDirective,
34-
RdxContextMenuTriggerDirective
35-
];
8+
/**
9+
* Context-menu-specific parts. The popup, items, checkbox/radio, submenus, separators, etc. come
10+
* from `@radix-ng/primitives/menu` (`RdxMenuModule`) and behave identically inside a context menu.
11+
*/
12+
const contextMenuImports = [RdxContextMenuRoot, RdxContextMenuTrigger];
3613

3714
@NgModule({
38-
imports: [..._imports],
39-
exports: [..._imports]
15+
imports: [...contextMenuImports],
16+
exports: [...contextMenuImports]
4017
})
4118
export class RdxContextMenuModule {}

packages/primitives/context-menu/src/context-menu-content.directive.ts

Lines changed: 0 additions & 47 deletions
This file was deleted.

packages/primitives/context-menu/src/context-menu-item-checkbox.directive.ts

Lines changed: 0 additions & 30 deletions
This file was deleted.

packages/primitives/context-menu/src/context-menu-item-indicator.directive.ts

Lines changed: 0 additions & 14 deletions
This file was deleted.

0 commit comments

Comments
 (0)