diff --git a/src/components/menu/Menu.test.ts b/src/components/menu/Menu.test.ts index a7fdd8a3f..51cd4dbb7 100644 --- a/src/components/menu/Menu.test.ts +++ b/src/components/menu/Menu.test.ts @@ -28,8 +28,8 @@ describe('solid-ui-menu syncItems', () => { await menu.updateComplete // Children should be assigned slot attributes - expect(child1.getAttribute('slot')).toBe('menu-item-0') - expect(child2.getAttribute('slot')).toBe('menu-item-1') + expect(child1.getAttribute('slot')).toBe('menu-item-index-0') + expect(child2.getAttribute('slot')).toBe('menu-item-index-1') // Trigger should keep its slot const trigger = menu.querySelector('[slot="trigger"]') @@ -49,7 +49,7 @@ describe('solid-ui-menu syncItems', () => { await menu.updateComplete // First sync assigned slots - expect(child1.getAttribute('slot')).toBe('menu-item-0') + expect(child1.getAttribute('slot')).toBe('menu-item-index-0') // Trigger a re-sync by adding another child (MutationObserver) const child2 = document.createElement('div') @@ -58,8 +58,8 @@ describe('solid-ui-menu syncItems', () => { await menu.updateComplete // Both children should still have valid slot assignments - expect(child1.getAttribute('slot')).toBe('menu-item-0') - expect(child2.getAttribute('slot')).toBe('menu-item-1') + expect(child1.getAttribute('slot')).toBe('menu-item-index-0') + expect(child2.getAttribute('slot')).toBe('menu-item-index-1') document.body.removeChild(menu) }) @@ -73,7 +73,7 @@ describe('solid-ui-menu syncItems', () => { menu.appendChild(child) await menu.updateComplete - expect(child.getAttribute('slot')).toBe('menu-item-0') + expect(child.getAttribute('slot')).toBe('menu-item-index-0') // Trigger should not have been re-assigned const trigger = menu.querySelector('[slot="trigger"]') @@ -81,4 +81,52 @@ describe('solid-ui-menu syncItems', () => { document.body.removeChild(menu) }) + + it('keeps keyed and positional slots distinct', async () => { + const menu = createMenu() + + const keyedChild = document.createElement('div') + keyedChild.dataset.menuKey = '1' + menu.appendChild(keyedChild) + + const positionalChild = document.createElement('div') + menu.appendChild(positionalChild) + + document.body.appendChild(menu) + await menu.updateComplete + + expect(keyedChild.getAttribute('slot')).toBe('menu-item-key-1') + expect(positionalChild.getAttribute('slot')).toBe('menu-item-index-1') + + document.body.removeChild(menu) + }) + + it('moves keyed dropdown items instead of recreating them when actions reorder', async () => { + const menu = createMenu() + const first = document.createElement('div') + first.dataset.menuKey = 'first' + const second = document.createElement('div') + second.dataset.menuKey = 'second' + menu.append(first, second) + + document.body.appendChild(menu) + await menu.updateComplete + + const firstWrapper = Array.from(menu.shadowRoot?.querySelectorAll('wa-dropdown-item') || []) + .find(item => item.children[0]?.getAttribute('name') === 'menu-item-key-first') + const secondWrapper = Array.from(menu.shadowRoot?.querySelectorAll('wa-dropdown-item') || []) + .find(item => item.children[0]?.getAttribute('name') === 'menu-item-key-second') + expect(firstWrapper).toBeTruthy() + expect(secondWrapper).toBeTruthy() + + menu.insertBefore(second, first) + await new Promise(resolve => setTimeout(resolve, 0)) + await menu.updateComplete + + const reorderedWrappers = Array.from(menu.shadowRoot?.querySelectorAll('wa-dropdown-item') || []) + expect(reorderedWrappers[0]).toBe(secondWrapper) + expect(reorderedWrappers[1]).toBe(firstWrapper) + + document.body.removeChild(menu) + }) }) diff --git a/src/components/menu/Menu.ts b/src/components/menu/Menu.ts index ce479b9d2..d43c5a692 100644 --- a/src/components/menu/Menu.ts +++ b/src/components/menu/Menu.ts @@ -1,6 +1,7 @@ import { customElement, WebComponent } from '@/lib/components' import { html } from 'lit' import { property, query, state } from 'lit/decorators.js' +import { repeat } from 'lit/directives/repeat.js' import type WaDropdown from '@awesome.me/webawesome/dist/components/dropdown/dropdown.js' import type WaDropdownItem from '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js' import type { WaDropdownSelectEvent } from '@awesome.me/webawesome' @@ -26,6 +27,7 @@ export default class Menu extends WebComponent { @state() private accessor items: { slot: string; }[] = [] + private slotToRestoreFocus: string | undefined private observer: MutationObserver = new MutationObserver(() => this.syncItems()) connectedCallback () { @@ -50,17 +52,22 @@ export default class Menu extends WebComponent { > - ${this.items.map( - (item) => - html` - - ` + ${repeat( + this.items, + item => item.slot, + item => html` + + ` )} ` } private syncItems (): void { + const activeElement = this.shadowRoot?.activeElement as WaDropdownItem | null + const activeSlot = activeElement?.children[0]?.getAttribute('name') + if (activeSlot) this.slotToRestoreFocus = activeSlot + // Exclude only the trigger element; include all other children // regardless of whether they already have a slot assigned. const items = Array.from(this.children).filter( @@ -68,7 +75,10 @@ export default class Menu extends WebComponent { ) this.items = items.map((item, index) => { - const slotName = `menu-item-${index}` + const key = item.getAttribute('data-menu-key') + const slotName = key + ? `menu-item-key-${encodeURIComponent(key)}` + : `menu-item-index-${index}` if (item.getAttribute('slot') !== slotName) { item.setAttribute('slot', slotName) @@ -78,6 +88,16 @@ export default class Menu extends WebComponent { }) } + protected updated (): void { + if (!this.slotToRestoreFocus) return + + const slotToRestore = this.slotToRestoreFocus + this.slotToRestoreFocus = undefined + const item = Array.from(this.shadowRoot?.querySelectorAll('wa-dropdown-item') || []) + .find(candidate => candidate.children[0]?.getAttribute('name') === slotToRestore) + item?.focus({ preventScroll: true }) + } + private onItemClick (event: Event) { const waItem = event.currentTarget as WaDropdownItem diff --git a/src/components/resource-actions-menu/ResourceActionsMenu.ts b/src/components/resource-actions-menu/ResourceActionsMenu.ts index 89e502f59..5b14cc477 100644 --- a/src/components/resource-actions-menu/ResourceActionsMenu.ts +++ b/src/components/resource-actions-menu/ResourceActionsMenu.ts @@ -1,6 +1,7 @@ import { customElement, WebComponent } from '@/lib/components' import { property } from 'lit/decorators.js' import { html, nothing } from 'lit' +import { keyed } from 'lit/directives/keyed.js' import '@/components/button' import '@/components/menu' import '@/components/menu-item' @@ -13,6 +14,7 @@ import styles from './ResourceActionsMenu.styles.css' import { isContainerSubject } from '@/utils/podUtils' type ResourceActionMenuItem = { + key?: string label: string icon?: HTMLElement | string action?: (event: Event) => void @@ -68,14 +70,17 @@ export default class ResourceActionsMenu extends WebComponent { More options - ${actionableMenuItems.map(item => html` - item.action(event)}> + ${actionableMenuItems.map(item => keyed(item.key ?? item.label, html` + item.action(event)} + > ${typeof item.icon === 'string' ? html`` : item.icon ?? html``} ${item.label} - `)} + `))} ${canEdit ? html`