@api-client/ui 0.4.1 → 0.4.3
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/.vscode/settings.json +1 -0
- package/build/src/index.d.ts +3 -0
- package/build/src/index.d.ts.map +1 -1
- package/build/src/index.js +4 -0
- package/build/src/index.js.map +1 -1
- package/build/src/lib/Dom.d.ts +5 -0
- package/build/src/lib/Dom.d.ts.map +1 -0
- package/build/src/lib/Dom.js +24 -0
- package/build/src/lib/Dom.js.map +1 -0
- package/build/src/md/button/internals/base.d.ts +27 -0
- package/build/src/md/button/internals/base.d.ts.map +1 -1
- package/build/src/md/button/internals/base.js +90 -1
- package/build/src/md/button/internals/base.js.map +1 -1
- package/build/src/md/icons/internals/Icon.js +2 -2
- package/build/src/md/icons/internals/Icon.js.map +1 -1
- package/build/src/md/list/internals/List.d.ts +1 -1
- package/build/src/md/list/internals/List.d.ts.map +1 -1
- package/build/src/md/list/internals/List.js +4 -4
- package/build/src/md/list/internals/List.js.map +1 -1
- package/build/src/md/list/internals/ListItem.d.ts +1 -0
- package/build/src/md/list/internals/ListItem.d.ts.map +1 -1
- package/build/src/md/list/internals/ListItem.js +10 -10
- package/build/src/md/list/internals/ListItem.js.map +1 -1
- package/build/src/md/list/internals/ListItem.styles.d.ts.map +1 -1
- package/build/src/md/list/internals/ListItem.styles.js +2 -20
- package/build/src/md/list/internals/ListItem.styles.js.map +1 -1
- package/build/src/md/menu/index.d.ts +4 -0
- package/build/src/md/menu/index.d.ts.map +1 -0
- package/build/src/md/menu/index.js +4 -0
- package/build/src/md/menu/index.js.map +1 -0
- package/build/src/md/menu/internal/Menu.d.ts +76 -0
- package/build/src/md/menu/internal/Menu.d.ts.map +1 -0
- package/build/src/md/menu/internal/Menu.js +235 -0
- package/build/src/md/menu/internal/Menu.js.map +1 -0
- package/build/src/md/menu/internal/Menu.styles.d.ts +3 -0
- package/build/src/md/menu/internal/Menu.styles.d.ts.map +1 -0
- package/build/src/md/menu/internal/Menu.styles.js +185 -0
- package/build/src/md/menu/internal/Menu.styles.js.map +1 -0
- package/build/src/md/menu/internal/MenuItem.d.ts +77 -0
- package/build/src/md/menu/internal/MenuItem.d.ts.map +1 -0
- package/build/src/md/menu/internal/MenuItem.js +216 -0
- package/build/src/md/menu/internal/MenuItem.js.map +1 -0
- package/build/src/md/menu/internal/MenuItem.styles.d.ts +3 -0
- package/build/src/md/menu/internal/MenuItem.styles.d.ts.map +1 -0
- package/build/src/md/menu/internal/MenuItem.styles.js +64 -0
- package/build/src/md/menu/internal/MenuItem.styles.js.map +1 -0
- package/build/src/md/menu/internal/SubMenu.d.ts +56 -0
- package/build/src/md/menu/internal/SubMenu.d.ts.map +1 -0
- package/build/src/md/menu/internal/SubMenu.js +171 -0
- package/build/src/md/menu/internal/SubMenu.js.map +1 -0
- package/build/src/md/menu/internal/SubMenu.styles.d.ts +3 -0
- package/build/src/md/menu/internal/SubMenu.styles.d.ts.map +1 -0
- package/build/src/md/menu/internal/SubMenu.styles.js +8 -0
- package/build/src/md/menu/internal/SubMenu.styles.js.map +1 -0
- package/build/src/md/menu/ui-menu-item.d.ts +20 -0
- package/build/src/md/menu/ui-menu-item.d.ts.map +1 -0
- package/build/src/md/menu/ui-menu-item.js +37 -0
- package/build/src/md/menu/ui-menu-item.js.map +1 -0
- package/build/src/md/menu/ui-menu.d.ts +22 -0
- package/build/src/md/menu/ui-menu.d.ts.map +1 -0
- package/build/src/md/menu/ui-menu.js +38 -0
- package/build/src/md/menu/ui-menu.js.map +1 -0
- package/build/src/md/menu/ui-sub-menu.d.ts +20 -0
- package/build/src/md/menu/ui-sub-menu.d.ts.map +1 -0
- package/build/src/md/menu/ui-sub-menu.js +37 -0
- package/build/src/md/menu/ui-sub-menu.js.map +1 -0
- package/build/src/mixins/FileDropMixin.d.ts.map +1 -1
- package/build/src/mixins/FileDropMixin.js +7 -8
- package/build/src/mixins/FileDropMixin.js.map +1 -1
- package/build/src/mixins/RenderableMixin.d.ts.map +1 -1
- package/build/src/mixins/RenderableMixin.js +2 -3
- package/build/src/mixins/RenderableMixin.js.map +1 -1
- package/demo/md/index.html +2 -0
- package/demo/md/menu/index.html +19 -0
- package/demo/md/menu/index.ts +154 -0
- package/package.json +2 -3
- package/src/index.ts +5 -0
- package/src/lib/Dom.ts +26 -0
- package/src/md/button/internals/base.ts +77 -0
- package/src/md/icons/internals/Icon.ts +2 -2
- package/src/md/list/internals/List.ts +4 -4
- package/src/md/list/internals/ListItem.styles.ts +2 -20
- package/src/md/list/internals/ListItem.ts +11 -11
- package/src/md/menu/README.md +253 -0
- package/src/md/menu/index.ts +3 -0
- package/src/md/menu/internal/Menu.styles.ts +185 -0
- package/src/md/menu/internal/Menu.ts +205 -0
- package/src/md/menu/internal/MenuItem.styles.ts +64 -0
- package/src/md/menu/internal/MenuItem.ts +217 -0
- package/src/md/menu/internal/SubMenu.styles.ts +8 -0
- package/src/md/menu/internal/SubMenu.ts +179 -0
- package/src/md/menu/ui-menu-item.ts +25 -0
- package/src/md/menu/ui-menu.ts +26 -0
- package/src/md/menu/ui-sub-menu.ts +25 -0
- package/src/mixins/FileDropMixin.ts +106 -107
- package/src/mixins/RenderableMixin.ts +107 -108
- package/test/md/menu/Menu.test.ts +509 -0
- package/test/md/menu/MenuIntegration.test.ts +426 -0
- package/test/md/menu/MenuItem.test.ts +361 -0
- package/test/md/menu/SubMenu.test.ts +411 -0
- /package/test/{ui → md}/button/UiButton.test.ts +0 -0
- /package/test/{ui → md}/button/UiIconButton.test.ts +0 -0
- /package/test/{ui → md}/chip/UiChip.test.ts +0 -0
- /package/test/{ui → md}/collapse/UiCollapse.test.ts +0 -0
- /package/test/{ui → md}/collapse/flex-layout.test.ts +0 -0
- /package/test/{ui → md}/date-time/DateTime.test.ts +0 -0
- /package/test/{ui → md}/dialog/UiDialog.test.ts +0 -0
- /package/test/{ui → md}/progress/UiProgressElement.test.ts +0 -0
- /package/test/{ui → md}/progress/UiRangeElement.test.ts +0 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# UI Menu Components
|
|
2
|
+
|
|
3
|
+
A set of accessible, themeable, and modern menu components for the web, built as Lit web components. This package includes `<ui-menu>`, `<ui-menu-item>`, and `<ui-sub-menu>` to create simple dropdowns or complex, deeply-nested contextual menus.
|
|
4
|
+
|
|
5
|
+
The implementation leverages modern web platform features like the **Popover API** for overlay management and the **CSS Anchor Positioning API** for robust submenu placement, with built-in fallbacks for older browsers.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Modern & Performant**: Built on top of the native Popover API.
|
|
10
|
+
- **Flexible Positioning**: Uses the CSS Anchor Positioning API for precise submenu placement, avoiding complex JavaScript calculations.
|
|
11
|
+
- **Clean HTML Structure**: Submenus are linked via attributes (`id`, `submenu`, `anchor`), not nested in slots, leading to a flatter and more maintainable DOM.
|
|
12
|
+
- **Deep Nesting**: Supports multiple levels of submenus out of the box.
|
|
13
|
+
- **Accessible**: Follows WAI-ARIA patterns for menus, with full keyboard navigation support.
|
|
14
|
+
- **Themeable**: Style the components using CSS Custom Properties to match your design system.
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
### Basic Menu
|
|
19
|
+
|
|
20
|
+
To create a simple menu, use a trigger element (like `<ui-button>`) and a `<ui-menu>` element. The trigger connects to the menu by setting its `popovertarget` attribute to the `id` of the menu.
|
|
21
|
+
|
|
22
|
+
```html
|
|
23
|
+
<!-- Trigger Button -->
|
|
24
|
+
<ui-button id="basic-menu-trigger" popovertarget="basic-menu">
|
|
25
|
+
Open Menu
|
|
26
|
+
</ui-button>
|
|
27
|
+
|
|
28
|
+
<!-- Menu -->
|
|
29
|
+
<ui-menu id="basic-menu">
|
|
30
|
+
<ui-menu-item>
|
|
31
|
+
<span slot="start"><ui-icon>add</ui-icon></span>
|
|
32
|
+
<span>New</span>
|
|
33
|
+
</ui-menu-item>
|
|
34
|
+
<ui-menu-item>
|
|
35
|
+
<span slot="start"><ui-icon>folder</ui-icon></span>
|
|
36
|
+
<span>Open</span>
|
|
37
|
+
</ui-menu-item>
|
|
38
|
+
<ui-menu-item disabled>
|
|
39
|
+
<span slot="start"><ui-icon>save</ui-icon></span>
|
|
40
|
+
<span>Save (Disabled)</span>
|
|
41
|
+
</ui-menu-item>
|
|
42
|
+
<div role="separator" style="height: 1px; background: #ccc; margin: 8px 0;"></div>
|
|
43
|
+
<ui-menu-item>
|
|
44
|
+
<span slot="start"><ui-icon>print</ui-icon></span>
|
|
45
|
+
<span>Print</span>
|
|
46
|
+
</ui-menu-item>
|
|
47
|
+
</ui-menu>
|
|
48
|
+
|
|
49
|
+
<script>
|
|
50
|
+
document.querySelector('#basic-menu').addEventListener('select', (e) => {
|
|
51
|
+
const { item, index } = e.detail;
|
|
52
|
+
console.log(`Selected: "${item.textContent.trim()}" at index ${index}`);
|
|
53
|
+
});
|
|
54
|
+
</script>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Menu with Submenus
|
|
58
|
+
|
|
59
|
+
For menus with nested submenus, the components use an attribute-based relationship for a clean and flexible structure.
|
|
60
|
+
|
|
61
|
+
1. Give the parent `<ui-menu-item>` an `id`.
|
|
62
|
+
2. Add a `submenu` attribute to the `<ui-menu-item>` pointing to the id of the `<ui-sub-menu>`.
|
|
63
|
+
3. Add an `anchor` attribute to the `<ui-sub-menu>` pointing back to the id of its parent `<ui-menu-item>`.
|
|
64
|
+
4. **Important**: Place the `<ui-sub-menu>` elements inside the light DOM of the parent `<ui-menu>` (as children of the main menu element), not as separate standalone elements.
|
|
65
|
+
|
|
66
|
+
This approach allows for deep nesting and ensures proper keyboard event propagation. The submenus must be children of the parent menu for keyboard navigation to work correctly across menu levels.
|
|
67
|
+
|
|
68
|
+
```html
|
|
69
|
+
<!-- Trigger Button -->
|
|
70
|
+
<ui-button id="submenu-trigger" popovertarget="submenu-demo">
|
|
71
|
+
Open Menu
|
|
72
|
+
</ui-button>
|
|
73
|
+
|
|
74
|
+
<!-- Main Menu -->
|
|
75
|
+
<ui-menu id="submenu-demo" @select="handleNestedMenuSelect">
|
|
76
|
+
<!-- Menu Item with a Submenu -->
|
|
77
|
+
<ui-menu-item id="file-item" submenu="file-submenu">
|
|
78
|
+
<span slot="start"><ui-icon>docs</ui-icon></span>
|
|
79
|
+
<span>File</span>
|
|
80
|
+
</ui-menu-item>
|
|
81
|
+
<ui-menu-item id="edit-item" submenu="edit-submenu">
|
|
82
|
+
<span slot="start"><ui-icon>edit</ui-icon></span>
|
|
83
|
+
<span>Edit</span>
|
|
84
|
+
</ui-menu-item>
|
|
85
|
+
<ui-menu-item>
|
|
86
|
+
<span slot="start"><ui-icon>visibility</ui-icon></span>
|
|
87
|
+
<span>View</span>
|
|
88
|
+
</ui-menu-item>
|
|
89
|
+
|
|
90
|
+
<!-- File Submenu (inside the main menu's light DOM) -->
|
|
91
|
+
<ui-sub-menu id="file-submenu" anchor="file-item">
|
|
92
|
+
<ui-menu-item>New File</ui-menu-item>
|
|
93
|
+
<ui-menu-item>Open File</ui-menu-item>
|
|
94
|
+
<!-- Nested Submenu Item -->
|
|
95
|
+
<ui-menu-item id="export-item" submenu="export-submenu">
|
|
96
|
+
<span slot="start"><ui-icon>file_export</ui-icon></span>
|
|
97
|
+
<span>Export</span>
|
|
98
|
+
</ui-menu-item>
|
|
99
|
+
|
|
100
|
+
<!-- Deeply Nested Export Submenu (inside the file submenu) -->
|
|
101
|
+
<ui-sub-menu id="export-submenu" anchor="export-item">
|
|
102
|
+
<ui-menu-item>Export as PDF</ui-menu-item>
|
|
103
|
+
<ui-menu-item>Export as PNG</ui-menu-item>
|
|
104
|
+
</ui-sub-menu>
|
|
105
|
+
</ui-sub-menu>
|
|
106
|
+
|
|
107
|
+
<!-- Edit Submenu (inside the main menu's light DOM) -->
|
|
108
|
+
<ui-sub-menu id="edit-submenu" anchor="edit-item">
|
|
109
|
+
<ui-menu-item>Undo</ui-menu-item>
|
|
110
|
+
<ui-menu-item>Redo</ui-menu-item>
|
|
111
|
+
<ui-menu-item>Cut</ui-menu-item>
|
|
112
|
+
</ui-sub-menu>
|
|
113
|
+
</ui-menu>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Components
|
|
117
|
+
|
|
118
|
+
### `<ui-menu>`
|
|
119
|
+
|
|
120
|
+
The main menu container that holds menu items and submenus.
|
|
121
|
+
|
|
122
|
+
#### Menu Attributes
|
|
123
|
+
|
|
124
|
+
- `open` (boolean) - Whether the menu is currently open
|
|
125
|
+
- `disabled` (boolean) - Whether the menu is disabled
|
|
126
|
+
- `popover` (string) - Native popover attribute, typically set to "auto"
|
|
127
|
+
- `id` (string) - Required for popover targeting
|
|
128
|
+
|
|
129
|
+
#### Menu Events
|
|
130
|
+
|
|
131
|
+
- `select` - Dispatched when a menu item is selected. Event detail contains `{ item, index }`
|
|
132
|
+
- `open` - Dispatched when the menu is opened
|
|
133
|
+
- `close` - Dispatched when the menu is closed
|
|
134
|
+
|
|
135
|
+
#### Menu Methods
|
|
136
|
+
|
|
137
|
+
- `show()` - Programmatically show the menu
|
|
138
|
+
- `hide()` - Programmatically hide the menu
|
|
139
|
+
- `togglePopover(force?)` - Toggle the menu's popover state
|
|
140
|
+
|
|
141
|
+
### `<ui-menu-item>`
|
|
142
|
+
|
|
143
|
+
Individual menu items that can trigger actions or open submenus.
|
|
144
|
+
|
|
145
|
+
#### Menu Item Attributes
|
|
146
|
+
|
|
147
|
+
- `disabled` (boolean) - Whether the menu item is disabled
|
|
148
|
+
- `submenu` (string) - ID of the associated submenu element
|
|
149
|
+
- `id` (string) - Required when the item has a submenu (for anchoring)
|
|
150
|
+
|
|
151
|
+
#### Menu Item Slots
|
|
152
|
+
|
|
153
|
+
- Default slot - The main content of the menu item
|
|
154
|
+
- `start` - Content before the main text (e.g., icons)
|
|
155
|
+
- `end` - Content after the main text
|
|
156
|
+
- `end-text` - Supporting text at the end
|
|
157
|
+
|
|
158
|
+
#### Menu Item Events
|
|
159
|
+
|
|
160
|
+
- `select` - Dispatched when the menu item is clicked (if no submenu)
|
|
161
|
+
- `submenu-open` - Dispatched when a submenu is opened
|
|
162
|
+
|
|
163
|
+
### `<ui-sub-menu>`
|
|
164
|
+
|
|
165
|
+
Submenu containers that extend the main menu component with additional positioning logic.
|
|
166
|
+
|
|
167
|
+
#### Sub-menu Attributes
|
|
168
|
+
|
|
169
|
+
- `anchor` (string) - ID of the parent menu item that this submenu is anchored to
|
|
170
|
+
- `open` (boolean) - Whether the submenu is currently open
|
|
171
|
+
- `disabled` (boolean) - Whether the submenu is disabled
|
|
172
|
+
- `popover` (string) - Native popover attribute, typically set to "auto"
|
|
173
|
+
- `id` (string) - Required for menu item targeting
|
|
174
|
+
|
|
175
|
+
#### Sub-menu Events
|
|
176
|
+
|
|
177
|
+
- `select` - Dispatched when a submenu item is selected
|
|
178
|
+
- `open` - Dispatched when the submenu is opened
|
|
179
|
+
- `close` - Dispatched when the submenu is closed
|
|
180
|
+
|
|
181
|
+
#### Sub-menu Methods
|
|
182
|
+
|
|
183
|
+
- `show()` - Programmatically show the submenu
|
|
184
|
+
- `hide()` - Programmatically hide the submenu
|
|
185
|
+
- `setParentMenu(menu)` - Set the parent menu for proper event handling
|
|
186
|
+
|
|
187
|
+
## Keyboard Navigation
|
|
188
|
+
|
|
189
|
+
The menu components support full keyboard navigation:
|
|
190
|
+
|
|
191
|
+
- **Arrow Keys**: Navigate between menu items
|
|
192
|
+
- **Enter/Space**: Select a menu item or open a submenu
|
|
193
|
+
- **Arrow Right**: Open a submenu (when focused on an item with submenu)
|
|
194
|
+
- **Arrow Left**: Close current submenu and return to parent
|
|
195
|
+
- **Escape**: Close the current menu/submenu
|
|
196
|
+
- **Home/End**: Jump to first/last menu item
|
|
197
|
+
|
|
198
|
+
## Styling
|
|
199
|
+
|
|
200
|
+
The components can be styled using CSS Custom Properties. Key styling hooks include:
|
|
201
|
+
|
|
202
|
+
```css
|
|
203
|
+
ui-menu {
|
|
204
|
+
--md-menu-container-color: white;
|
|
205
|
+
--md-menu-container-elevation: 2;
|
|
206
|
+
--md-menu-item-height: 48px;
|
|
207
|
+
/* ... other custom properties */
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Accessibility
|
|
212
|
+
|
|
213
|
+
The menu components follow WAI-ARIA best practices:
|
|
214
|
+
|
|
215
|
+
- Menu containers have `role="menu"`
|
|
216
|
+
- Menu items have `role="menuitem"`
|
|
217
|
+
- Items with submenus have `aria-haspopup="true"` and `aria-expanded` attributes
|
|
218
|
+
- Full keyboard navigation is supported
|
|
219
|
+
- Proper focus management when opening/closing menus
|
|
220
|
+
- Screen reader announcements for menu state changes
|
|
221
|
+
|
|
222
|
+
## Best Practices
|
|
223
|
+
|
|
224
|
+
### Structure Requirements
|
|
225
|
+
|
|
226
|
+
1. **Light DOM Placement**: Always place `<ui-sub-menu>` elements inside the light DOM of their parent `<ui-menu>` or `<ui-sub-menu>`. This ensures proper keyboard event propagation.
|
|
227
|
+
|
|
228
|
+
2. **ID Management**: Ensure menu items that have submenus have unique `id` attributes, and their corresponding submenus reference them via the `anchor` attribute.
|
|
229
|
+
|
|
230
|
+
3. **Popover Attributes**: Include `popover="auto"` on both `<ui-menu>` and `<ui-sub-menu>` elements for proper native popover behavior.
|
|
231
|
+
|
|
232
|
+
### Event Handling
|
|
233
|
+
|
|
234
|
+
```javascript
|
|
235
|
+
// Listen for menu selections
|
|
236
|
+
document.querySelector('#my-menu').addEventListener('select', (e) => {
|
|
237
|
+
const { item, index } = e.detail;
|
|
238
|
+
console.log(`Selected: "${item.textContent.trim()}" at index ${index}`);
|
|
239
|
+
});
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Trigger Connection
|
|
243
|
+
|
|
244
|
+
Connect triggers to menus using the native `popovertarget` attribute:
|
|
245
|
+
|
|
246
|
+
```html
|
|
247
|
+
<ui-button popovertarget="my-menu">Open Menu</ui-button>
|
|
248
|
+
<ui-menu id="my-menu">
|
|
249
|
+
<!-- menu items -->
|
|
250
|
+
</ui-menu>
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
This leverages the native Popover API for optimal performance and browser integration.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { css } from 'lit'
|
|
2
|
+
|
|
3
|
+
export default css`
|
|
4
|
+
:host {
|
|
5
|
+
display: none;
|
|
6
|
+
position-area: bottom span-right;
|
|
7
|
+
position-try: normal flip-block;
|
|
8
|
+
position: absolute;
|
|
9
|
+
margin: 0;
|
|
10
|
+
padding: 0;
|
|
11
|
+
border: none;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
:host(:popover-open) {
|
|
15
|
+
display: block;
|
|
16
|
+
background-color: var(--md-sys-color-surface);
|
|
17
|
+
border-radius: var(--md-sys-shape-corner-extra-small);
|
|
18
|
+
box-shadow: var(--md-sys-elevation-3);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.menu-container {
|
|
22
|
+
min-width: 200px;
|
|
23
|
+
padding: 8px 0;
|
|
24
|
+
outline: none;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.menu-divider {
|
|
28
|
+
height: 1px;
|
|
29
|
+
background-color: var(--md-sys-color-outline-variant);
|
|
30
|
+
margin: 8px 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* Menu Item Styles */
|
|
34
|
+
.menu-item {
|
|
35
|
+
position: relative;
|
|
36
|
+
display: flex;
|
|
37
|
+
align-items: center;
|
|
38
|
+
min-height: 48px;
|
|
39
|
+
padding: 0 16px;
|
|
40
|
+
cursor: pointer;
|
|
41
|
+
outline: none;
|
|
42
|
+
transition: background-color 0.2s ease;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.menu-item:hover {
|
|
46
|
+
background-color: var(--md-sys-color-surface-variant);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.menu-item:focus {
|
|
50
|
+
background-color: var(--md-sys-color-surface-variant);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.menu-item[disabled] {
|
|
54
|
+
opacity: 0.38;
|
|
55
|
+
cursor: not-allowed;
|
|
56
|
+
pointer-events: none;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.menu-item-content {
|
|
60
|
+
display: flex;
|
|
61
|
+
align-items: center;
|
|
62
|
+
width: 100%;
|
|
63
|
+
gap: 12px;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.menu-item-icon {
|
|
67
|
+
display: flex;
|
|
68
|
+
align-items: center;
|
|
69
|
+
justify-content: center;
|
|
70
|
+
width: 24px;
|
|
71
|
+
height: 24px;
|
|
72
|
+
color: var(--md-sys-color-on-surface);
|
|
73
|
+
font-size: 20px;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.menu-item-label {
|
|
77
|
+
flex: 1;
|
|
78
|
+
color: var(--md-sys-color-on-surface);
|
|
79
|
+
font-family: var(--md-sys-typescale-label-large-font-family-name);
|
|
80
|
+
font-size: var(--md-sys-typescale-label-large-font-size);
|
|
81
|
+
font-weight: var(--md-sys-typescale-label-large-font-weight);
|
|
82
|
+
line-height: var(--md-sys-typescale-label-large-line-height);
|
|
83
|
+
letter-spacing: var(--md-sys-typescale-label-large-letter-spacing);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.menu-item-arrow {
|
|
87
|
+
display: flex;
|
|
88
|
+
align-items: center;
|
|
89
|
+
justify-content: center;
|
|
90
|
+
width: 24px;
|
|
91
|
+
height: 24px;
|
|
92
|
+
color: var(--md-sys-color-on-surface);
|
|
93
|
+
font-size: 18px;
|
|
94
|
+
font-weight: 500;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.menu-item-with-submenu {
|
|
98
|
+
position: relative;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.menu-item-with-submenu:hover .menu-item-arrow {
|
|
102
|
+
color: var(--md-sys-color-primary);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* Sub-menu Styles */
|
|
106
|
+
.submenu-container {
|
|
107
|
+
min-width: 200px;
|
|
108
|
+
max-width: 320px;
|
|
109
|
+
background-color: var(--md-sys-color-surface);
|
|
110
|
+
border-radius: var(--md-sys-shape-corner-extra-small);
|
|
111
|
+
box-shadow: var(--md-sys-elevation-level3);
|
|
112
|
+
padding: 8px 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* Submenu positioning with Anchor API */
|
|
116
|
+
ui-sub-menu {
|
|
117
|
+
display: none;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
ui-sub-menu:popover-open {
|
|
121
|
+
display: block;
|
|
122
|
+
background-color: var(--md-sys-color-surface);
|
|
123
|
+
border-radius: var(--md-sys-shape-corner-extra-small);
|
|
124
|
+
box-shadow: var(--md-sys-elevation-level3);
|
|
125
|
+
min-width: 200px;
|
|
126
|
+
max-width: 320px;
|
|
127
|
+
padding: 8px 0;
|
|
128
|
+
z-index: 1000;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* Fallback positioning for browsers without anchor positioning */
|
|
132
|
+
@supports not (anchor-name: --test) {
|
|
133
|
+
ui-sub-menu:popover-open {
|
|
134
|
+
position: fixed;
|
|
135
|
+
transform: translateX(200px);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* Focus Ring */
|
|
140
|
+
md-focus-ring {
|
|
141
|
+
--md-focus-ring-color: var(--md-sys-color-primary);
|
|
142
|
+
--md-focus-ring-width: 2px;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* Ripple Effect */
|
|
146
|
+
ui-ripple {
|
|
147
|
+
--md-ripple-color: var(--md-sys-color-primary);
|
|
148
|
+
--md-ripple-opacity: 0.12;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* Responsive Design */
|
|
152
|
+
@media (max-width: 600px) {
|
|
153
|
+
.menu-container {
|
|
154
|
+
min-width: 180px;
|
|
155
|
+
max-width: 280px;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.submenu-container {
|
|
159
|
+
min-width: 180px;
|
|
160
|
+
max-width: 280px;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* High Contrast Mode */
|
|
165
|
+
@media (prefers-contrast: high) {
|
|
166
|
+
.menu-container {
|
|
167
|
+
border: 1px solid var(--md-sys-color-outline);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.submenu-container {
|
|
171
|
+
border: 1px solid var(--md-sys-color-outline);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.menu-divider {
|
|
175
|
+
background-color: var(--md-sys-color-outline);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* Reduced Motion */
|
|
180
|
+
@media (prefers-reduced-motion: reduce) {
|
|
181
|
+
.menu-item {
|
|
182
|
+
transition: none;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
`
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { html, PropertyValues, TemplateResult } from 'lit'
|
|
2
|
+
import { property, state, queryAssignedElements } from 'lit/decorators.js'
|
|
3
|
+
import { classMap } from 'lit/directives/class-map.js'
|
|
4
|
+
import { nanoid } from 'nanoid'
|
|
5
|
+
import UiList from '../../list/internals/List.js'
|
|
6
|
+
import UiMenuItem from './MenuItem.js'
|
|
7
|
+
import UiSubMenu from './SubMenu.js'
|
|
8
|
+
import { setDisabled } from '../../../lib/disabled.js'
|
|
9
|
+
import UiListItem from '../../list/internals/ListItem.js'
|
|
10
|
+
import { bound } from '../../../decorators/bound.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Material Design 3 Menu component with sub-menu support.
|
|
14
|
+
* Uses Popover API and Anchor Positioning API for modern positioning.
|
|
15
|
+
*
|
|
16
|
+
* @fires select - Dispatched when a menu item is selected
|
|
17
|
+
* @fires close - Dispatched when the menu is closed
|
|
18
|
+
*/
|
|
19
|
+
export default class Menu extends UiList {
|
|
20
|
+
/**
|
|
21
|
+
* Whether the menu is currently open
|
|
22
|
+
* @attribute
|
|
23
|
+
*/
|
|
24
|
+
@property({ type: Boolean, reflect: true }) accessor open = false
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether the menu is disabled
|
|
28
|
+
* @attribute
|
|
29
|
+
*/
|
|
30
|
+
@property({ type: Boolean, reflect: true }) accessor disabled = false
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Currently active sub-menu
|
|
34
|
+
*/
|
|
35
|
+
@state() accessor activeSubMenu: UiSubMenu | null = null
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Assigned menu items from light DOM
|
|
39
|
+
*/
|
|
40
|
+
@queryAssignedElements({ selector: 'ui-menu-item' }) protected accessor assignedMenuItems!: UiMenuItem[]
|
|
41
|
+
|
|
42
|
+
constructor() {
|
|
43
|
+
super()
|
|
44
|
+
this.selector = 'ui-menu-item'
|
|
45
|
+
this.ariaExpanded = 'false'
|
|
46
|
+
this.addEventListener('beforetoggle', this.handleBeforeToggle.bind(this))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
override connectedCallback(): void {
|
|
50
|
+
super.connectedCallback()
|
|
51
|
+
this.setAttribute('role', 'menu')
|
|
52
|
+
this.setAttribute('tabindex', '-1')
|
|
53
|
+
if (!this.hasAttribute('popover')) {
|
|
54
|
+
this.setAttribute('popover', 'auto')
|
|
55
|
+
}
|
|
56
|
+
if (!this.id) {
|
|
57
|
+
this.id = nanoid()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
protected override updated(changedProperties: PropertyValues<this>): void {
|
|
62
|
+
super.updated(changedProperties)
|
|
63
|
+
|
|
64
|
+
if (changedProperties.has('disabled')) {
|
|
65
|
+
setDisabled(this, this.disabled)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
override togglePopover(force?: boolean): boolean {
|
|
70
|
+
this.open = !this.open
|
|
71
|
+
this.ariaExpanded = String(this.open)
|
|
72
|
+
this.tabIndex = this.open ? 0 : -1
|
|
73
|
+
if (this.open) {
|
|
74
|
+
this.focus()
|
|
75
|
+
}
|
|
76
|
+
return super.togglePopover(force)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Shows the menu
|
|
81
|
+
*/
|
|
82
|
+
show(): void {
|
|
83
|
+
this.tabIndex = 0 // Make menu focusable
|
|
84
|
+
this.ariaExpanded = 'true'
|
|
85
|
+
this.showPopover()
|
|
86
|
+
this.open = true
|
|
87
|
+
this.focus()
|
|
88
|
+
this.dispatchEvent(new CustomEvent('open', { bubbles: false, composed: true }))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Hides the menu
|
|
93
|
+
*/
|
|
94
|
+
hide(): void {
|
|
95
|
+
this.tabIndex = -1
|
|
96
|
+
this.ariaExpanded = 'false'
|
|
97
|
+
this.hidePopover()
|
|
98
|
+
this.open = false
|
|
99
|
+
this.closeSubMenu()
|
|
100
|
+
this.dispatchEvent(new CustomEvent('close', { bubbles: false, composed: true }))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Handles beforetoggle event from popover
|
|
105
|
+
*/
|
|
106
|
+
protected handleBeforeToggle(e: Event): void {
|
|
107
|
+
const toggleEvent = e as ToggleEvent
|
|
108
|
+
if (toggleEvent.newState === 'closed') {
|
|
109
|
+
this.open = false
|
|
110
|
+
this.closeSubMenu()
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Handles keyboard navigation for the menu
|
|
116
|
+
*/
|
|
117
|
+
override handleKeydown(e: KeyboardEvent): void {
|
|
118
|
+
if (!this.open || e.defaultPrevented) return
|
|
119
|
+
|
|
120
|
+
switch (e.key) {
|
|
121
|
+
case 'Escape':
|
|
122
|
+
e.preventDefault()
|
|
123
|
+
this.hide()
|
|
124
|
+
break
|
|
125
|
+
case 'ArrowRight':
|
|
126
|
+
e.preventDefault()
|
|
127
|
+
this.openSubMenu()
|
|
128
|
+
break
|
|
129
|
+
case 'ArrowLeft':
|
|
130
|
+
e.preventDefault()
|
|
131
|
+
this.closeSubMenu()
|
|
132
|
+
break
|
|
133
|
+
default:
|
|
134
|
+
// Let the parent UiList handle other keys
|
|
135
|
+
super.handleKeydown(e)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
@bound
|
|
140
|
+
handleSubMenuSelect(e: CustomEvent): void {
|
|
141
|
+
super.notifySelect(e.detail.item, e.detail.index)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Opens the sub-menu for the currently active item
|
|
146
|
+
*/
|
|
147
|
+
protected openSubMenu(): void {
|
|
148
|
+
const activeItem = this.activeListItem as UiMenuItem
|
|
149
|
+
if (activeItem?.hasSubMenu) {
|
|
150
|
+
activeItem.openSubMenu()
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Closes the currently open sub-menu
|
|
156
|
+
*/
|
|
157
|
+
closeSubMenu(): void {
|
|
158
|
+
if (this.activeSubMenu) {
|
|
159
|
+
this.activeSubMenu.removeEventListener('select', this.handleSubMenuSelect as EventListener)
|
|
160
|
+
this.activeSubMenu.hide()
|
|
161
|
+
this.activeSubMenu = null
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Sets the active sub-menu
|
|
167
|
+
*/
|
|
168
|
+
setActiveSubMenu(subMenu: UiSubMenu | null): void {
|
|
169
|
+
this.activeSubMenu = subMenu
|
|
170
|
+
subMenu?.addEventListener('select', this.handleSubMenuSelect as EventListener)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
override notifySelect(item: UiListItem, index?: number): boolean {
|
|
174
|
+
this.hide()
|
|
175
|
+
return super.notifySelect(item, index)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Handles sub-menu opening
|
|
180
|
+
*/
|
|
181
|
+
protected handleSubMenuOpen(e: CustomEvent): void {
|
|
182
|
+
const subMenu = e.detail.subMenu
|
|
183
|
+
this.setActiveSubMenu(subMenu)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Handles slot changes to update menu items
|
|
188
|
+
*/
|
|
189
|
+
protected handleSlotChange(): void {
|
|
190
|
+
// Update the items list when slot content changes
|
|
191
|
+
this.updateItems()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
override render(): TemplateResult {
|
|
195
|
+
const classes = classMap({
|
|
196
|
+
'menu-container': true,
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
return html`
|
|
200
|
+
<div class=${classes}>
|
|
201
|
+
<slot @slotchange=${this.handleSlotChange}></slot>
|
|
202
|
+
</div>
|
|
203
|
+
`
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { css } from 'lit'
|
|
2
|
+
|
|
3
|
+
export default css`
|
|
4
|
+
:host {
|
|
5
|
+
display: block;
|
|
6
|
+
position: relative;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.menu-item {
|
|
10
|
+
position: relative;
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
min-height: 48px;
|
|
14
|
+
padding: 0 16px;
|
|
15
|
+
cursor: pointer;
|
|
16
|
+
outline: none;
|
|
17
|
+
transition: background-color 0.2s ease;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.menu-item:hover {
|
|
21
|
+
background-color: var(--md-sys-color-surface-variant);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.menu-item:focus {
|
|
25
|
+
background-color: var(--md-sys-color-surface-variant);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.menu-item[disabled] {
|
|
29
|
+
opacity: 0.38;
|
|
30
|
+
cursor: not-allowed;
|
|
31
|
+
pointer-events: none;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.menu-item-with-submenu {
|
|
35
|
+
position: relative;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.menu-item-with-submenu:hover .menu-item-arrow {
|
|
39
|
+
color: var(--md-sys-color-primary);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.menu-item-arrow {
|
|
43
|
+
display: flex;
|
|
44
|
+
align-items: center;
|
|
45
|
+
justify-content: center;
|
|
46
|
+
width: 24px;
|
|
47
|
+
height: 24px;
|
|
48
|
+
color: var(--md-sys-color-on-surface);
|
|
49
|
+
font-size: 18px;
|
|
50
|
+
font-weight: 500;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Focus Ring */
|
|
54
|
+
md-focus-ring {
|
|
55
|
+
--md-focus-ring-color: var(--md-sys-color-primary);
|
|
56
|
+
--md-focus-ring-width: 2px;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* Ripple Effect */
|
|
60
|
+
ui-ripple {
|
|
61
|
+
--md-ripple-color: var(--md-sys-color-primary);
|
|
62
|
+
--md-ripple-opacity: 0.12;
|
|
63
|
+
}
|
|
64
|
+
`
|