@api-client/ui 0.4.2 → 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.
Files changed (109) hide show
  1. package/.vscode/settings.json +1 -0
  2. package/build/src/index.d.ts +3 -0
  3. package/build/src/index.d.ts.map +1 -1
  4. package/build/src/index.js +4 -0
  5. package/build/src/index.js.map +1 -1
  6. package/build/src/lib/Dom.d.ts +5 -0
  7. package/build/src/lib/Dom.d.ts.map +1 -0
  8. package/build/src/lib/Dom.js +24 -0
  9. package/build/src/lib/Dom.js.map +1 -0
  10. package/build/src/md/button/internals/base.d.ts +27 -0
  11. package/build/src/md/button/internals/base.d.ts.map +1 -1
  12. package/build/src/md/button/internals/base.js +90 -1
  13. package/build/src/md/button/internals/base.js.map +1 -1
  14. package/build/src/md/icons/internals/Icon.js +2 -2
  15. package/build/src/md/icons/internals/Icon.js.map +1 -1
  16. package/build/src/md/list/internals/List.d.ts +1 -1
  17. package/build/src/md/list/internals/List.d.ts.map +1 -1
  18. package/build/src/md/list/internals/List.js +4 -4
  19. package/build/src/md/list/internals/List.js.map +1 -1
  20. package/build/src/md/list/internals/ListItem.d.ts +1 -0
  21. package/build/src/md/list/internals/ListItem.d.ts.map +1 -1
  22. package/build/src/md/list/internals/ListItem.js +10 -10
  23. package/build/src/md/list/internals/ListItem.js.map +1 -1
  24. package/build/src/md/list/internals/ListItem.styles.d.ts.map +1 -1
  25. package/build/src/md/list/internals/ListItem.styles.js +2 -20
  26. package/build/src/md/list/internals/ListItem.styles.js.map +1 -1
  27. package/build/src/md/menu/index.d.ts +4 -0
  28. package/build/src/md/menu/index.d.ts.map +1 -0
  29. package/build/src/md/menu/index.js +4 -0
  30. package/build/src/md/menu/index.js.map +1 -0
  31. package/build/src/md/menu/internal/Menu.d.ts +76 -0
  32. package/build/src/md/menu/internal/Menu.d.ts.map +1 -0
  33. package/build/src/md/menu/internal/Menu.js +235 -0
  34. package/build/src/md/menu/internal/Menu.js.map +1 -0
  35. package/build/src/md/menu/internal/Menu.styles.d.ts +3 -0
  36. package/build/src/md/menu/internal/Menu.styles.d.ts.map +1 -0
  37. package/build/src/md/menu/internal/Menu.styles.js +185 -0
  38. package/build/src/md/menu/internal/Menu.styles.js.map +1 -0
  39. package/build/src/md/menu/internal/MenuItem.d.ts +77 -0
  40. package/build/src/md/menu/internal/MenuItem.d.ts.map +1 -0
  41. package/build/src/md/menu/internal/MenuItem.js +216 -0
  42. package/build/src/md/menu/internal/MenuItem.js.map +1 -0
  43. package/build/src/md/menu/internal/MenuItem.styles.d.ts +3 -0
  44. package/build/src/md/menu/internal/MenuItem.styles.d.ts.map +1 -0
  45. package/build/src/md/menu/internal/MenuItem.styles.js +64 -0
  46. package/build/src/md/menu/internal/MenuItem.styles.js.map +1 -0
  47. package/build/src/md/menu/internal/SubMenu.d.ts +56 -0
  48. package/build/src/md/menu/internal/SubMenu.d.ts.map +1 -0
  49. package/build/src/md/menu/internal/SubMenu.js +171 -0
  50. package/build/src/md/menu/internal/SubMenu.js.map +1 -0
  51. package/build/src/md/menu/internal/SubMenu.styles.d.ts +3 -0
  52. package/build/src/md/menu/internal/SubMenu.styles.d.ts.map +1 -0
  53. package/build/src/md/menu/internal/SubMenu.styles.js +8 -0
  54. package/build/src/md/menu/internal/SubMenu.styles.js.map +1 -0
  55. package/build/src/md/menu/ui-menu-item.d.ts +20 -0
  56. package/build/src/md/menu/ui-menu-item.d.ts.map +1 -0
  57. package/build/src/md/menu/ui-menu-item.js +37 -0
  58. package/build/src/md/menu/ui-menu-item.js.map +1 -0
  59. package/build/src/md/menu/ui-menu.d.ts +22 -0
  60. package/build/src/md/menu/ui-menu.d.ts.map +1 -0
  61. package/build/src/md/menu/ui-menu.js +38 -0
  62. package/build/src/md/menu/ui-menu.js.map +1 -0
  63. package/build/src/md/menu/ui-sub-menu.d.ts +20 -0
  64. package/build/src/md/menu/ui-sub-menu.d.ts.map +1 -0
  65. package/build/src/md/menu/ui-sub-menu.js +37 -0
  66. package/build/src/md/menu/ui-sub-menu.js.map +1 -0
  67. package/build/src/mixins/FileDropMixin.d.ts.map +1 -1
  68. package/build/src/mixins/FileDropMixin.js +7 -8
  69. package/build/src/mixins/FileDropMixin.js.map +1 -1
  70. package/build/src/mixins/RenderableMixin.d.ts.map +1 -1
  71. package/build/src/mixins/RenderableMixin.js +2 -3
  72. package/build/src/mixins/RenderableMixin.js.map +1 -1
  73. package/demo/md/index.html +2 -0
  74. package/demo/md/menu/index.html +19 -0
  75. package/demo/md/menu/index.ts +154 -0
  76. package/package.json +2 -3
  77. package/src/index.ts +5 -0
  78. package/src/lib/Dom.ts +26 -0
  79. package/src/md/button/internals/base.ts +77 -0
  80. package/src/md/icons/internals/Icon.ts +2 -2
  81. package/src/md/list/internals/List.ts +4 -4
  82. package/src/md/list/internals/ListItem.styles.ts +2 -20
  83. package/src/md/list/internals/ListItem.ts +11 -11
  84. package/src/md/menu/README.md +253 -0
  85. package/src/md/menu/index.ts +3 -0
  86. package/src/md/menu/internal/Menu.styles.ts +185 -0
  87. package/src/md/menu/internal/Menu.ts +205 -0
  88. package/src/md/menu/internal/MenuItem.styles.ts +64 -0
  89. package/src/md/menu/internal/MenuItem.ts +217 -0
  90. package/src/md/menu/internal/SubMenu.styles.ts +8 -0
  91. package/src/md/menu/internal/SubMenu.ts +179 -0
  92. package/src/md/menu/ui-menu-item.ts +25 -0
  93. package/src/md/menu/ui-menu.ts +26 -0
  94. package/src/md/menu/ui-sub-menu.ts +25 -0
  95. package/src/mixins/FileDropMixin.ts +106 -107
  96. package/src/mixins/RenderableMixin.ts +107 -108
  97. package/test/md/menu/Menu.test.ts +509 -0
  98. package/test/md/menu/MenuIntegration.test.ts +426 -0
  99. package/test/md/menu/MenuItem.test.ts +361 -0
  100. package/test/md/menu/SubMenu.test.ts +411 -0
  101. /package/test/{ui → md}/button/UiButton.test.ts +0 -0
  102. /package/test/{ui → md}/button/UiIconButton.test.ts +0 -0
  103. /package/test/{ui → md}/chip/UiChip.test.ts +0 -0
  104. /package/test/{ui → md}/collapse/UiCollapse.test.ts +0 -0
  105. /package/test/{ui → md}/collapse/flex-layout.test.ts +0 -0
  106. /package/test/{ui → md}/date-time/DateTime.test.ts +0 -0
  107. /package/test/{ui → md}/dialog/UiDialog.test.ts +0 -0
  108. /package/test/{ui → md}/progress/UiProgressElement.test.ts +0 -0
  109. /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,3 @@
1
+ export { UiMenuElement } from './ui-menu.js'
2
+ export { UiMenuItemElement } from './ui-menu-item.js'
3
+ export { UiSubMenuElement } from './ui-sub-menu.js'
@@ -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
+ `