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 +113 -0
- package/index.html +22 -0
- package/package.json +28 -0
- package/src/app.js +44 -0
- package/src/assets/edit.svg +1 -0
- package/src/assets/home.svg +1 -0
- package/src/assets/search.svg +1 -0
- package/src/assets/settings.svg +1 -0
- package/src/assets/share.svg +1 -0
- package/src/assets/user.svg +1 -0
- package/src/main-menu-data.js +47 -0
- package/src/radial-context-menu/radial-context-menu.css +225 -0
- package/src/radial-context-menu/radial-context-menu.js +326 -0
- package/src/second-menu-data.js +26 -0
- package/src/styles.css +66 -0
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
|
+

|
|
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
|
+
}
|