radial-context-menu 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # Radial Context Menu
2
+
3
+ A premium, glassmorphism-style radial context menu for the web. Built with vanilla JavaScript and CSS, featuring smooth animations, nested sub-menus, and support for both Emojis and SVG icons.
4
+
5
+ ![Demo Mockup](https://via.placeholder.com/800x400?text=Radial+Context+Menu+Demo)
6
+
7
+ ## Features
8
+
9
+ - 🚀 **Vanilla JS**: Zero dependencies.
10
+ - 💎 **Glassmorphism**: Modern, frosted-glass aesthetic.
11
+ - 📁 **Nested Menus**: Support for infinite nesting depths.
12
+ - 🖼️ **Icon Support**: Use any Emoji or SVG file.
13
+ - 🎯 **Context Aware**: Restrict the menu to specific DOM elements via selectors.
14
+ - ⚡ **Performant**: Uses event delegation for efficient event handling.
15
+
16
+ ---
17
+
18
+ ## 🚀 Initialization
19
+
20
+ To use the menu, instantiate the `RadialContextMenu` class, set your configuration properties, and call `.init()`.
21
+
22
+ ```javascript
23
+ import { RadialContextMenu } from "./src/radial-context-menu/radial-context-menu.js";
24
+
25
+ const menu = new RadialContextMenu();
26
+
27
+ // 1. Set the data source
28
+ menu.itemsSource = [
29
+ { name: "Home", image: "🏠" },
30
+ {
31
+ name: "Settings",
32
+ image: "⚙️",
33
+ children: [
34
+ { name: "Profile", image: "👤" },
35
+ { name: "Security", image: "🔒" },
36
+ ],
37
+ },
38
+ ];
39
+
40
+ // 2. Set the trigger selector (Optional - if null, triggers everywhere)
41
+ menu.selector = ".context-area";
42
+
43
+ // 3. Initialize
44
+ menu.init();
45
+ ```
46
+
47
+ ---
48
+
49
+ ## 📡 Events
50
+
51
+ The menu provides several lifecycle and interaction hooks.
52
+
53
+ | Event | Callback Signature | Description |
54
+ | -------------- | ------------------ | -------------------------------------------------------- |
55
+ | `onInit` | `(menu) => void` | Fires after the menu is created and events are attached. |
56
+ | `onOpen` | `(menu) => void` | Fires when the menu is displayed. |
57
+ | `onClose` | `(menu) => void` | Fires after the menu closure animation finishes. |
58
+ | `onHover` | `(item) => void` | Fires when a menu segment is hovered. |
59
+ | `onSelectItem` | `(item) => void` | Fires when a leaf item (no children) is clicked. |
60
+
61
+ **Example:**
62
+
63
+ ```javascript
64
+ menu.onSelectItem = (item) => {
65
+ console.log(`User clicked on: ${item.name}`);
66
+ };
67
+
68
+ menu.onOpen = () => {
69
+ // Close other UI elements or pause activities
70
+ console.log("Menu is now visible");
71
+ };
72
+ ```
73
+
74
+ ---
75
+
76
+ ## 🎨 Icon Features
77
+
78
+ The widget supports two types of icons out of the box.
79
+
80
+ ### 1. Emoji Icons
81
+
82
+ Simply pass a Unicode emoji string to the `image` property. This is the easiest way to get started without extra assets.
83
+
84
+ ```javascript
85
+ { name: 'Delete', image: '🗑️' }
86
+ ```
87
+
88
+ ### 2. SVG Icons
89
+
90
+ Pass a path to an SVG file. The component will automatically detect the `.svg` extension and render it as an image.
91
+
92
+ ```javascript
93
+ { name: 'Edit', image: 'src/assets/edit.svg' }
94
+ ```
95
+
96
+ **Styling SVGs:**
97
+ The menu includes a default CSS filter (brightness/invert) to ensure SVG icons appear white against the dark glass background. You can customize this in `radial-context-menu.css`.
98
+
99
+ ---
100
+
101
+ ## 🛠️ Data Structure
102
+
103
+ Each item in the `itemsSource` array follows this structure:
104
+
105
+ ```typescript
106
+ interface MenuItem {
107
+ name: string; // Displayed in the center button on hover
108
+ image: string; // Emoji string or path to .svg file
109
+ children?: []; // Optional nested items
110
+ }
111
+ ```
112
+
113
+ ---
package/index.html ADDED
@@ -0,0 +1,22 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Radial Context Menu</title>
7
+ <link rel="stylesheet" href="src/styles.css" />
8
+ <link rel="stylesheet" href="dist/radial-context-menu.min.css" />
9
+ </head>
10
+ <body>
11
+ <div class="content">
12
+ <h1>Radial context menu</h1>
13
+ <p>Right-click to open the menu.</p>
14
+ </div>
15
+ <div class="content-svg">
16
+ <h1>Radial context menu with SVG icons</h1>
17
+ <p>Right-click to open the menu.</p>
18
+ </div>
19
+ <script type="module" src="dist/radial-context-menu.min.js"></script>
20
+ <script type="module" src="src/app.js"></script>
21
+ </body>
22
+ </html>
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "radial-context-menu",
3
+ "version": "1.0.0",
4
+ "description": "Radial context menu.",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1",
8
+ "start": "http-server"
9
+ },
10
+ "keywords": [
11
+ "radial",
12
+ "contextmenu"
13
+ ],
14
+ "homepage": "https://github.com/devilbd/radial-context-menu#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/devilbd/radial-context-menu/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/devilbd/radial-context-menu.git"
21
+ },
22
+ "license": "ISC",
23
+ "author": "Vladimir Parchev (AI)",
24
+ "type": "commonjs",
25
+ "devDependencies": {
26
+ "http-server": "^14.1.1"
27
+ }
28
+ }
package/src/app.js ADDED
@@ -0,0 +1,44 @@
1
+ // import { RadialContextMenu } from "./radial-context-menu/radial-context-menu.js";
2
+ import { RadialContextMenu } from "../../dist/radial-context-menu.min.js";
3
+ import { mainMenuItems } from "./main-menu-data.js";
4
+ import { svgMenuItems } from "./second-menu-data.js";
5
+
6
+ export class App {
7
+ mainMenu;
8
+ secondaryMenu;
9
+
10
+ start() {
11
+ this.setupMainVariant();
12
+ this.setupSecondaryMenu();
13
+ }
14
+
15
+ setupMainVariant() {
16
+ this.mainMenu = new RadialContextMenu();
17
+ this.mainMenu.itemsSource = mainMenuItems;
18
+ this.mainMenu.selector = '.content'; // Only on header for main variant
19
+ this.mainMenu.onSelectItem = (item) => console.log("Main Menu Selected:", item.name);
20
+ this.mainMenu.onOpen = () => {
21
+ this.secondaryMenu.close();
22
+ }
23
+ this.mainMenu.init();
24
+ }
25
+
26
+ setupSecondaryMenu() {
27
+ this.secondaryMenu = new RadialContextMenu();
28
+ this.secondaryMenu.itemsSource = svgMenuItems;
29
+ this.secondaryMenu.selector = '.content-svg'; // Only on paragraph for SVG variant
30
+ this.secondaryMenu.onSelectItem = (item) => console.log("Secondary Menu Selected:", item.name);
31
+ this.secondaryMenu.onHover = (item) => console.log("Secondary Menu Hover:", item.name);
32
+ this.secondaryMenu.onOpen = () => {
33
+ this.mainMenu.close();
34
+ }
35
+ this.secondaryMenu.init();
36
+
37
+ console.log("App started. Right-click 'header' for Emoji menu, and 'paragraph' for SVG menu.");
38
+ }
39
+ }
40
+
41
+ (() => {
42
+ const app = new App();
43
+ app.start();
44
+ })();
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
@@ -0,0 +1,47 @@
1
+ export const mainMenuItems = [
2
+ {
3
+ name: 'home',
4
+ image: '🏠',
5
+ children: [
6
+ { name: 'dashboard', image: '📊' },
7
+ { name: 'analytics', image: '📈' },
8
+ {
9
+ name: 'reports',
10
+ image: '📝',
11
+ children: [
12
+ { name: 'annual', image: '📅' },
13
+ { name: 'monthly', image: '📆' },
14
+ {
15
+ name: 'weekly',
16
+ image: '🗓️',
17
+ children: [
18
+ { name: 'draft', image: '📋' },
19
+ { name: 'final', image: '✅' }
20
+ ]
21
+ }
22
+ ]
23
+ }
24
+ ]
25
+ },
26
+ {
27
+ name: 'edit',
28
+ image: '✏️',
29
+ children: [
30
+ { name: 'copy', image: '📋' },
31
+ { name: 'paste', image: '📥' },
32
+ { name: 'cut', image: '✂️' }
33
+ ]
34
+ },
35
+ { name: 'delete', image: '🗑️' },
36
+ {
37
+ name: 'share',
38
+ image: '🔗',
39
+ children: [
40
+ { name: 'email', image: '📧' },
41
+ { name: 'twitter', image: '🐦' },
42
+ { name: 'facebook', image: '👥' }
43
+ ]
44
+ },
45
+ { name: 'settings', image: '⚙️' },
46
+ { name: 'profile', image: '👤' },
47
+ ];
@@ -0,0 +1,225 @@
1
+ /* Radial Menu Styles */
2
+ .radial-menu {
3
+ position: fixed;
4
+ border-radius: 50%;
5
+ z-index: 1000;
6
+ pointer-events: none;
7
+ opacity: 0;
8
+ transform: scale(0.8);
9
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
10
+ background: transparent;
11
+ overflow: hidden;
12
+ contain: paint;
13
+ /* Professional shadow: sharp edge + soft ambient occlusion */
14
+ box-shadow:
15
+ 0 0 0 1px rgba(255, 255, 255, 0.05),
16
+ 0 10px 40px rgba(0, 0, 0, 0.5);
17
+ border: 1px solid rgba(255, 255, 255, 0.08);
18
+ background: var(--glass-bg);
19
+ backdrop-filter: blur(16px);
20
+ -webkit-backdrop-filter: blur(16px);
21
+ }
22
+
23
+ .radial-menu.active {
24
+ opacity: 1;
25
+ transform: scale(1);
26
+ pointer-events: auto;
27
+ }
28
+
29
+ .menu-segment {
30
+ position: absolute;
31
+ width: 100%;
32
+ height: 100%;
33
+ cursor: pointer;
34
+ display: flex;
35
+ justify-content: center;
36
+ align-items: center;
37
+ pointer-events: auto;
38
+ transform: translateZ(0);
39
+ will-change: clip-path;
40
+ isolation: isolate;
41
+ }
42
+
43
+ .segment-glass {
44
+ position: absolute;
45
+ top: 0;
46
+ left: 0;
47
+ width: 100%;
48
+ height: 100%;
49
+ background: radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.1), transparent);
50
+ background-color: var(--glass-bg);
51
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
52
+ z-index: 1;
53
+ /* Inner glow for glass depth */
54
+ box-shadow:
55
+ inset 0 1px 1px rgba(255, 255, 255, 0.2),
56
+ inset 0 -1px 1px rgba(0, 0, 0, 0.3);
57
+ }
58
+
59
+ .segment-shine {
60
+ position: absolute;
61
+ top: 0;
62
+ left: 0;
63
+ width: 100%;
64
+ height: 100%;
65
+ background: var(--gloss-shine);
66
+ opacity: 0.6;
67
+ z-index: 2;
68
+ pointer-events: none;
69
+ transition: opacity 0.3s ease;
70
+ }
71
+
72
+ .menu-segment:hover .segment-glass {
73
+ background-color: rgba(30, 30, 30, 0.9);
74
+ box-shadow:
75
+ inset 0 1px 3px rgba(255, 255, 255, 0.4),
76
+ inset 0 -1px 3px rgba(0, 0, 0, 0.5);
77
+ }
78
+
79
+ .menu-segment:hover .segment-shine {
80
+ opacity: 1;
81
+ }
82
+
83
+ .segment-label {
84
+ position: absolute;
85
+ transform: translate(-50%, -50%);
86
+ font-size: 1.6rem;
87
+ pointer-events: none;
88
+ transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
89
+ z-index: 3;
90
+ display: flex;
91
+ align-items: center;
92
+ justify-content: center;
93
+ width: 40px;
94
+ height: 40px;
95
+ }
96
+
97
+ .segment-icon {
98
+ width: 24px;
99
+ height: 24px;
100
+ filter: brightness(0) invert(1);
101
+ transition: all 0.3s ease;
102
+ }
103
+
104
+ .menu-segment:hover .segment-label {
105
+ transform: translate(-50%, -50%) scale(1.2);
106
+ filter: drop-shadow(0 0 10px rgba(255, 255, 255, 0.5));
107
+ }
108
+
109
+ .center-button {
110
+ position: absolute;
111
+ top: 50%;
112
+ left: 50%;
113
+ transform: translate(-50%, -50%);
114
+ width: 110px;
115
+ height: 110px;
116
+ background: linear-gradient(145deg, #333, #000);
117
+ border: 1px solid rgba(255, 255, 255, 0.3);
118
+ border-radius: 50%;
119
+ display: grid;
120
+ place-items: center;
121
+ cursor: pointer;
122
+ z-index: 10;
123
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
124
+ /* Chrome-like multi-layered shadow */
125
+ box-shadow:
126
+ 0 0 0 1px rgba(255, 255, 255, 0.1),
127
+ 0 10px 30px rgba(0, 0, 0, 0.8),
128
+ inset 0 2px 5px rgba(255, 255, 255, 0.2),
129
+ inset 0 -2px 10px rgba(0, 0, 0, 0.5);
130
+ pointer-events: auto;
131
+ overflow: hidden;
132
+ }
133
+
134
+ .center-button:hover {
135
+ transform: translate(-50%, -50%) scale(1.08);
136
+ background: linear-gradient(145deg, #444, #111);
137
+ border-color: rgba(255, 255, 255, 0.5);
138
+ box-shadow:
139
+ 0 0 0 1px rgba(255, 255, 255, 0.2),
140
+ 0 15px 40px rgba(0, 0, 0, 0.9),
141
+ inset 0 2px 10px rgba(255, 255, 255, 0.3);
142
+ }
143
+
144
+ /* Title Pop Styles */
145
+ .menu-title-pop {
146
+ position: absolute;
147
+ top: 50%;
148
+ left: 50%;
149
+ transform: translate(-50%, -50%);
150
+ width: 100px; /* Full width of button content area */
151
+ text-align: center;
152
+ z-index: 15;
153
+ pointer-events: none;
154
+ opacity: 0;
155
+ transition: all 0.25s cubic-bezier(0.23, 1, 0.32, 1);
156
+ font-size: 0.85rem; /* Professional and readable */
157
+ font-weight: 700;
158
+ color: #fff;
159
+ text-transform: uppercase;
160
+ letter-spacing: 1.2px;
161
+ text-shadow: 0 0 15px rgba(255, 255, 255, 0.5);
162
+ white-space: nowrap;
163
+ overflow: hidden;
164
+ text-overflow: ellipsis;
165
+ }
166
+
167
+ .menu-title-pop.visible {
168
+ opacity: 1;
169
+ transform: translate(-50%, -50%) scale(1.1);
170
+ }
171
+
172
+ .center-button span {
173
+ font-size: 1.8rem; /* Reverted to large for X */
174
+ color: var(--text-color);
175
+ display: block;
176
+ width: 90px; /* Ample space within 110px button */
177
+ text-align: center;
178
+ white-space: nowrap;
179
+ overflow: hidden;
180
+ text-overflow: ellipsis;
181
+ transition: all 0.3s ease;
182
+ text-shadow: 0 0 10px rgba(255, 255, 255, 0.3);
183
+ pointer-events: none;
184
+ }
185
+ .items-container {
186
+ position: absolute;
187
+ top: 0;
188
+ left: 0;
189
+ width: 100%;
190
+ height: 100%;
191
+ transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
192
+ }
193
+
194
+ .items-container.entering {
195
+ opacity: 0;
196
+ transform: scale(0.85);
197
+ }
198
+
199
+ .items-container.exiting {
200
+ opacity: 0;
201
+ transform: scale(1.15);
202
+ }
203
+
204
+ .items-container.active {
205
+ opacity: 1;
206
+ transform: scale(1);
207
+ }
208
+
209
+ .submenu-indicator {
210
+ position: absolute;
211
+ width: 4px;
212
+ height: 4px;
213
+ background: white;
214
+ border-radius: 50%;
215
+ transform: translate(-50%, -50%);
216
+ opacity: 0.6;
217
+ z-index: 4;
218
+ box-shadow: 0 0 5px rgba(255, 255, 255, 0.5);
219
+ pointer-events: none;
220
+ }
221
+
222
+ .menu-segment:hover .submenu-indicator {
223
+ opacity: 1;
224
+ transform: translate(-50%, -50%) scale(1.5);
225
+ }
@@ -0,0 +1,326 @@
1
+ export class RadialContextMenu {
2
+ // Properties to be set before init()
3
+ itemsSource = [];
4
+ onHover;
5
+ onSelectItem;
6
+ onClose;
7
+ onOpen;
8
+ onInit;
9
+ selector; // Optional: restrict context menu to specific elements
10
+ menu;
11
+ isOpen = false;
12
+ radius = 150; // pixels
13
+ navigationStack = []; // Breadcrumbs for nested menus
14
+
15
+ init() {
16
+ this.createMenu();
17
+ this.createCenterButton();
18
+ this.attachEvents();
19
+
20
+ if (this.onInit) {
21
+ this.onInit(this);
22
+ }
23
+ }
24
+
25
+ get currentItems() {
26
+ if (this.navigationStack.length === 0) return this.itemsSource;
27
+ return this.navigationStack[this.navigationStack.length - 1].children || [];
28
+ }
29
+
30
+ createMenu() {
31
+ this.menu = document.createElement('div');
32
+ this.menu.className = 'radial-menu';
33
+ this.menu.style.width = `${this.radius * 2}px`;
34
+ this.menu.style.height = `${this.radius * 2}px`;
35
+ this.menu.style.display = 'none';
36
+
37
+ // Create title pop element
38
+ this.titlePop = document.createElement('div');
39
+ this.titlePop.className = 'menu-title-pop';
40
+ this.menu.appendChild(this.titlePop);
41
+
42
+ // Items container to allow swapping segments
43
+ this.itemsContainer = null;
44
+ this.renderSegments();
45
+
46
+ document.body.appendChild(this.menu);
47
+ }
48
+
49
+ renderSegments(direction = 'in') {
50
+ const oldContainer = this.itemsContainer;
51
+
52
+ // Create new container for the next layer
53
+ const newContainer = document.createElement('div');
54
+ newContainer.className = `items-container entering`;
55
+ if (direction === 'out') {
56
+ newContainer.className = `items-container exiting`;
57
+ }
58
+
59
+ const items = this.currentItems;
60
+ const segmentAngle = 360 / items.length;
61
+
62
+ items.forEach((item, index) => {
63
+ const segment = this.createSegment(item, index, segmentAngle);
64
+ newContainer.appendChild(segment);
65
+ });
66
+
67
+ this.menu.appendChild(newContainer);
68
+
69
+ // Trigger animations
70
+ requestAnimationFrame(() => {
71
+ if (oldContainer) {
72
+ oldContainer.classList.add(direction === 'in' ? 'exiting' : 'entering');
73
+ oldContainer.classList.remove('active');
74
+ setTimeout(() => oldContainer.remove(), 400);
75
+ }
76
+
77
+ newContainer.classList.remove('entering', 'exiting');
78
+ newContainer.classList.add('active');
79
+ this.itemsContainer = newContainer;
80
+ });
81
+ }
82
+
83
+ createCenterButton() {
84
+ this.centerButton = document.createElement('div');
85
+ this.centerButton.className = 'center-button';
86
+ this.centerButton.innerHTML = '<span>×</span>'; // Default icon/text
87
+
88
+ this.centerButton.onclick = (e) => {
89
+ e.stopPropagation();
90
+ if (this.navigationStack.length > 0) {
91
+ this.goBack();
92
+ } else {
93
+ this.close();
94
+ }
95
+ };
96
+
97
+ this.menu.appendChild(this.centerButton);
98
+ }
99
+
100
+ updateCenterButton() {
101
+ const iconSpan = this.centerButton.querySelector('span');
102
+ if (this.navigationStack.length > 0) {
103
+ iconSpan.textContent = '←'; // Back arrow
104
+ } else {
105
+ iconSpan.textContent = '×'; // Close icon
106
+ }
107
+ }
108
+
109
+ goBack() {
110
+ this.navigationStack.pop();
111
+ this.renderSegments('out');
112
+ this.updateCenterButton();
113
+ }
114
+
115
+ navigateTo(item) {
116
+ if (item.children && item.children.length > 0) {
117
+ this.navigationStack.push(item);
118
+ this.renderSegments('in');
119
+ this.updateCenterButton();
120
+ }
121
+ }
122
+
123
+ createSegment(item, index, angleWidth) {
124
+ const startAngle = index * angleWidth - 90;
125
+ const endAngle = (index + 1) * angleWidth - 90;
126
+
127
+ const segment = document.createElement('div');
128
+ segment.className = 'menu-segment';
129
+ if (item.children && item.children.length > 0) {
130
+ segment.classList.add('has-children');
131
+ }
132
+ segment.style.zIndex = index + 1;
133
+
134
+ // Store item data association securely
135
+ segment._menuItem = item;
136
+
137
+ const innerDist = 26;
138
+ const outerDist = 49.5;
139
+
140
+ const points = [];
141
+ const step = 0.5;
142
+
143
+ for (let a = startAngle; a <= endAngle; a += step) {
144
+ points.push(this.getPoint(a, outerDist));
145
+ }
146
+ points.push(this.getPoint(endAngle, outerDist));
147
+
148
+ for (let a = endAngle; a >= startAngle; a -= step) {
149
+ points.push(this.getPoint(a, innerDist));
150
+ }
151
+ points.push(this.getPoint(startAngle, innerDist));
152
+
153
+ const clipPathStr = `polygon(${points.map(p => `${p.x.toFixed(6)}% ${p.y.toFixed(6)}%`).join(', ')})`;
154
+ segment.style.clipPath = clipPathStr;
155
+ segment.style.webkitClipPath = clipPathStr;
156
+
157
+ const glass = document.createElement('div');
158
+ glass.className = 'segment-glass';
159
+ segment.appendChild(glass);
160
+
161
+ const shine = document.createElement('div');
162
+ shine.className = 'segment-shine';
163
+ segment.appendChild(shine);
164
+
165
+ const label = document.createElement('span');
166
+ label.className = 'segment-label';
167
+
168
+ if (item.image && typeof item.image === 'string' && item.image.endsWith('.svg')) {
169
+ const img = document.createElement('img');
170
+ img.src = item.image;
171
+ img.className = 'segment-icon';
172
+ label.appendChild(img);
173
+ } else {
174
+ label.textContent = item.image;
175
+ }
176
+
177
+ const midAngle = startAngle + (angleWidth / 2);
178
+ const labelPos = this.getPoint(midAngle, 39);
179
+ label.style.left = `${labelPos.x}%`;
180
+ label.style.top = `${labelPos.y}%`;
181
+
182
+ segment.appendChild(label);
183
+
184
+ if (item.children && item.children.length > 0) {
185
+ const indicator = document.createElement('div');
186
+ indicator.className = 'submenu-indicator';
187
+ const indicatorPos = this.getPoint(midAngle, 47.5);
188
+ indicator.style.left = `${indicatorPos.x}%`;
189
+ indicator.style.top = `${indicatorPos.y}%`;
190
+ segment.appendChild(indicator);
191
+ }
192
+
193
+ return segment;
194
+ }
195
+
196
+ getPoint(angle, distance = 50) {
197
+ const rad = (angle * Math.PI) / 180;
198
+ return {
199
+ x: 50 + distance * Math.cos(rad),
200
+ y: 50 + distance * Math.sin(rad)
201
+ };
202
+ }
203
+
204
+ attachEvents() {
205
+ this._handlers = {
206
+ contextmenu: (e) => {
207
+ if (this.selector) {
208
+ const target = e.target.closest(this.selector);
209
+ if (!target) return;
210
+ }
211
+
212
+ e.preventDefault();
213
+ if (this.isOpen) {
214
+ this.close();
215
+ } else {
216
+ this.open(e.clientX, e.clientY);
217
+ }
218
+ },
219
+ click: (e) => {
220
+ // Handle outside clicks to close
221
+ if (!this.menu.contains(e.target) && this.isOpen) {
222
+ this.close();
223
+ }
224
+
225
+ // Handle segment clicks via delegation
226
+ const segment = e.target.closest('.menu-segment');
227
+ if (segment && segment._menuItem) {
228
+ e.stopPropagation();
229
+ const item = segment._menuItem;
230
+ if (item.children && item.children.length > 0) {
231
+ this.navigateTo(item);
232
+ } else {
233
+ if (this.onSelectItem) {
234
+ this.onSelectItem(item);
235
+ }
236
+ this.close();
237
+ }
238
+ }
239
+ },
240
+ mouseover: (e) => {
241
+ const segment = e.target.closest('.menu-segment');
242
+ if (segment && segment._menuItem) {
243
+ const item = segment._menuItem;
244
+
245
+ if (this.onHover) {
246
+ this.onHover(item);
247
+ }
248
+
249
+ if (this.titlePop) {
250
+ this.titlePop.textContent = item.name;
251
+ this.titlePop.classList.add('visible');
252
+ if (this.centerButton) {
253
+ this.centerButton.querySelector('span').style.opacity = '0';
254
+ }
255
+ }
256
+ }
257
+ },
258
+ mouseout: (e) => {
259
+ const segment = e.target.closest('.menu-segment');
260
+ if (segment) {
261
+ if (this.titlePop) {
262
+ this.titlePop.classList.remove('visible');
263
+ if (this.centerButton) {
264
+ this.centerButton.querySelector('span').style.opacity = '1';
265
+ }
266
+ }
267
+ }
268
+ }
269
+ };
270
+
271
+ document.addEventListener('contextmenu', this._handlers.contextmenu);
272
+ document.addEventListener('click', this._handlers.click);
273
+ this.menu.addEventListener('mouseover', this._handlers.mouseover);
274
+ this.menu.addEventListener('mouseout', this._handlers.mouseout);
275
+ }
276
+
277
+ destroy() {
278
+ if (this._handlers) {
279
+ document.removeEventListener('contextmenu', this._handlers.contextmenu);
280
+ document.removeEventListener('click', this._handlers.click);
281
+ if (this.menu) {
282
+ this.menu.removeEventListener('mouseover', this._handlers.mouseover);
283
+ this.menu.removeEventListener('mouseout', this._handlers.mouseout);
284
+ }
285
+ this._handlers = null;
286
+ }
287
+
288
+ if (this.menu && this.menu.parentNode) {
289
+ this.menu.parentNode.removeChild(this.menu);
290
+ }
291
+
292
+ this.menu = null;
293
+ this.isOpen = false;
294
+ }
295
+
296
+ open(x, y) {
297
+ this.navigationStack = []; // Reset navigation when opening fresh
298
+ this.renderSegments();
299
+ this.updateCenterButton();
300
+
301
+ this.menu.style.left = `${x - this.radius}px`;
302
+ this.menu.style.top = `${y - this.radius}px`;
303
+ this.menu.style.display = 'block';
304
+ // Force reflow
305
+ this.menu.offsetHeight;
306
+ this.menu.classList.add('active');
307
+ this.isOpen = true;
308
+
309
+ if (this.onOpen) {
310
+ this.onOpen(this);
311
+ }
312
+ }
313
+
314
+ close() {
315
+ this.menu.classList.remove('active');
316
+ setTimeout(() => {
317
+ if (!this.menu.classList.contains('active')) {
318
+ this.menu.style.display = 'none';
319
+ if (this.onClose) {
320
+ this.onClose(this);
321
+ }
322
+ }
323
+ }, 300);
324
+ this.isOpen = false;
325
+ }
326
+ }
@@ -0,0 +1,26 @@
1
+ export const svgMenuItems = [
2
+ {
3
+ name: 'home',
4
+ image: 'src/assets/home.svg',
5
+ children: [
6
+ { name: 'search', image: 'src/assets/search.svg' },
7
+ { name: 'profile', image: 'src/assets/user.svg' }
8
+ ]
9
+ },
10
+ {
11
+ name: 'edit',
12
+ image: 'src/assets/edit.svg',
13
+ children: [
14
+ { name: 'copy', image: 'src/assets/edit.svg' },
15
+ { name: 'paste', image: 'src/assets/edit.svg' }
16
+ ]
17
+ },
18
+ {
19
+ name: 'share',
20
+ image: 'src/assets/share.svg'
21
+ },
22
+ {
23
+ name: 'settings',
24
+ image: 'src/assets/settings.svg'
25
+ }
26
+ ];
package/src/styles.css ADDED
@@ -0,0 +1,66 @@
1
+ @import "./radial-context-menu/radial-context-menu.css";
2
+
3
+ :root {
4
+ --bg-color: #0a0a0a;
5
+ --text-color: #f0f0f0;
6
+ --accent-color: #3b82f6;
7
+ --glass-bg: rgba(10, 10, 10, 0.8);
8
+ --glass-border: rgba(255, 255, 255, 0.12);
9
+ }
10
+
11
+ * {
12
+ margin: 0;
13
+ padding: 0;
14
+ box-sizing: border-box;
15
+ }
16
+
17
+ body {
18
+ font-family:
19
+ "Outfit",
20
+ -apple-system,
21
+ BlinkMacSystemFont,
22
+ "Segoe UI",
23
+ Roboto,
24
+ Helvetica,
25
+ Arial,
26
+ sans-serif;
27
+ background-color: var(--bg-color);
28
+ color: var(--text-color);
29
+ display: flex;
30
+ flex-direction: column;
31
+ justify-content: center;
32
+ gap: 2rem;
33
+ align-items: center;
34
+ min-height: 100vh;
35
+ overflow: hidden;
36
+ user-select: none;
37
+ }
38
+
39
+ h1 {
40
+ font-size: 2.5rem;
41
+ margin-bottom: 1rem;
42
+ background: linear-gradient(45deg, #fff, var(--accent-color));
43
+ -webkit-background-clip: text;
44
+ background-clip: text;
45
+ -webkit-text-fill-color: transparent;
46
+ text-transform: uppercase;
47
+ }
48
+
49
+ p {
50
+ opacity: 0.7;
51
+ font-size: 1.1rem;
52
+ }
53
+
54
+ .content,.content-svg {
55
+ text-align: center;
56
+ z-index: 1;
57
+ }
58
+
59
+ .content:hover,.content-svg:hover {
60
+ cursor: pointer;
61
+ border: 1px solid var(--accent-color);
62
+ border-radius: 10px;
63
+ padding: 10px;
64
+ box-shadow: 0 0 10px var(--accent-color);
65
+ transition: all 0.3s ease;
66
+ }