Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 54 additions & 6 deletions src/components/menu/Menu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]')
Expand All @@ -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')
Expand All @@ -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)
})
Expand All @@ -73,12 +73,60 @@ 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"]')
expect(trigger?.getAttribute('slot')).toBe('trigger')

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)
})
})
32 changes: 26 additions & 6 deletions src/components/menu/Menu.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 () {
Expand All @@ -50,25 +52,33 @@ export default class Menu extends WebComponent {
>
<slot name="trigger" slot="trigger"></slot>

${this.items.map(
(item) =>
html`<wa-dropdown-item @click=${this.onItemClick}>
<slot name=${item.slot}></slot>
</wa-dropdown-item>`
${repeat(
this.items,
item => item.slot,
item => html`<wa-dropdown-item @click=${this.onItemClick}>
<slot name=${item.slot}></slot>
</wa-dropdown-item>`
)}
</wa-dropdown>
`
}

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(
(child) => child.getAttribute('slot') !== 'trigger'
)

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)
Expand All @@ -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<WaDropdownItem>('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

Expand Down
11 changes: 8 additions & 3 deletions src/components/resource-actions-menu/ResourceActionsMenu.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -68,14 +70,17 @@ export default class ResourceActionsMenu extends WebComponent {
<icon-lucide-ellipsis-vertical slot="icon" class="ellipsisIcon"></icon-lucide-ellipsis-vertical>
<span class="sr-only">More options</span>
</solid-ui-button>
${actionableMenuItems.map(item => html`
<solid-ui-menu-item @solid-ui-select=${(event: Event) => item.action(event)}>
${actionableMenuItems.map(item => keyed(item.key ?? item.label, html`
<solid-ui-menu-item
data-menu-key=${item.key ?? nothing}
@solid-ui-select=${(event: Event) => item.action(event)}
>
${typeof item.icon === 'string'
? html`<img slot="left-icon" src=${item.icon} alt="">`
: item.icon ?? html`<icon-lucide-file slot="left-icon" aria-hidden="true"></icon-lucide-file>`}
${item.label}
</solid-ui-menu-item>
`)}
`))}
${canEdit
? html`
<solid-ui-menu-item @solid-ui-select=${this.handleEditingClick}>
Expand Down
Loading