diff --git a/web/__test__/components/Wrapper/mount-engine.test.ts b/web/__test__/components/Wrapper/mount-engine.test.ts index 497a81e6e8..56a58983c9 100644 --- a/web/__test__/components/Wrapper/mount-engine.test.ts +++ b/web/__test__/components/Wrapper/mount-engine.test.ts @@ -248,6 +248,46 @@ describe('mount-engine', () => { expect(lastUAppPortal).toBe('#unraid-api-modals-virtual'); }); + it('should sanitize conflicting dropdown ids that legacy plugins target', async () => { + await mountUnifiedApp(); + + const trigger = document.createElement('button'); + trigger.setAttribute('id', 'reka-dropdown-menu-trigger-1'); + document.body.appendChild(trigger); + + const content = document.createElement('div'); + content.setAttribute('id', 'reka-dropdown-menu-content-1'); + content.setAttribute('aria-labelledby', 'reka-dropdown-menu-trigger-1'); + document.body.appendChild(content); + + trigger.setAttribute('aria-controls', 'reka-dropdown-menu-content-1'); + + await vi.waitFor(() => { + expect(trigger.getAttribute('id')).toBe('reka-menu-trigger-1'); + expect(trigger.getAttribute('aria-controls')).toBe('reka-menu-content-1'); + expect(content.getAttribute('id')).toBe('reka-menu-content-1'); + expect(content.getAttribute('aria-labelledby')).toBe('reka-menu-trigger-1'); + }); + }); + + it('should leave legacy webgui dropdown ids untouched', async () => { + await mountUnifiedApp(); + + const legacyMenu = document.createElement('ul'); + legacyMenu.setAttribute('id', 'dropdown-vm-1234'); + document.body.appendChild(legacyMenu); + + const legacyTrigger = document.createElement('button'); + legacyTrigger.setAttribute('id', 'vm-1234'); + legacyTrigger.setAttribute('aria-controls', 'dropdown-vm-1234'); + document.body.appendChild(legacyTrigger); + + await vi.waitFor(() => { + expect(legacyMenu.getAttribute('id')).toBe('dropdown-vm-1234'); + expect(legacyTrigger.getAttribute('aria-controls')).toBe('dropdown-vm-1234'); + }); + }); + it('should decorate the parent container when requested', async () => { const container = document.createElement('div'); container.id = 'container'; diff --git a/web/src/components/Wrapper/mount-engine.ts b/web/src/components/Wrapper/mount-engine.ts index f70c0c7792..50272d6b02 100644 --- a/web/src/components/Wrapper/mount-engine.ts +++ b/web/src/components/Wrapper/mount-engine.ts @@ -11,6 +11,7 @@ import { createI18nInstance, ensureLocale, getWindowLocale } from '~/helpers/i18 // Import Pinia for use in Vue apps import { globalPinia } from '~/store/globalPinia'; +import { enableDropdownIdCompatibility } from '~/utils/dropdownIdCompatibility'; import { ensureUnapiScope, ensureUnapiScopeForSelectors, observeUnapiScope } from '~/utils/unapiScope'; // Ensure Apollo client is singleton @@ -128,6 +129,8 @@ function parsePropsFromElement(element: Element): Record { // Create and mount unified app with shared context export async function mountUnifiedApp() { + enableDropdownIdCompatibility(); + // Create a minimal app just for context sharing const app = createApp({ name: 'UnifiedContextApp', diff --git a/web/src/utils/dropdownIdCompatibility.ts b/web/src/utils/dropdownIdCompatibility.ts new file mode 100644 index 0000000000..b316703fc4 --- /dev/null +++ b/web/src/utils/dropdownIdCompatibility.ts @@ -0,0 +1,80 @@ +const CONFLICTING_ID_SEGMENT = 'reka-dropdown-'; +const SAFE_ID_SEGMENT = 'menu-'; + +const ATTRIBUTE_NAMES = ['id', 'aria-controls', 'aria-labelledby'] as const; +const SELECTOR = ATTRIBUTE_NAMES.map((attribute) => `[${attribute}*="${CONFLICTING_ID_SEGMENT}"]`).join( + ', ' +); + +let compatibilityObserver: MutationObserver | null = null; + +function normalizeValue(value: string): string { + return value.replace(/reka-dropdown-menu-/g, `reka-${SAFE_ID_SEGMENT}`); +} + +function sanitizeAttribute(element: Element, attributeName: (typeof ATTRIBUTE_NAMES)[number]): void { + const value = element.getAttribute(attributeName); + if (!value || !value.includes(CONFLICTING_ID_SEGMENT)) { + return; + } + + element.setAttribute(attributeName, normalizeValue(value)); +} + +function sanitizeElement(element: Element): void { + ATTRIBUTE_NAMES.forEach((attributeName) => { + sanitizeAttribute(element, attributeName); + }); +} + +function sanitizeTree(root: ParentNode): void { + root.querySelectorAll(SELECTOR).forEach((element) => { + sanitizeElement(element); + }); +} + +export function enableDropdownIdCompatibility(doc: Document = document): void { + if (compatibilityObserver) { + sanitizeTree(doc); + return; + } + + sanitizeTree(doc); + + const observerRoot = doc.body ?? doc.documentElement; + if (!observerRoot) { + return; + } + + compatibilityObserver = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes' && mutation.target instanceof Element) { + const attributeName = mutation.attributeName; + if ( + attributeName === 'id' || + attributeName === 'aria-controls' || + attributeName === 'aria-labelledby' + ) { + sanitizeAttribute(mutation.target, attributeName); + } + return; + } + + mutation.addedNodes.forEach((node) => { + if (!(node instanceof Element)) { + return; + } + + sanitizeElement(node); + sanitizeTree(node); + }); + }); + }); + + compatibilityObserver.observe(observerRoot, { + attributes: true, + attributeFilter: [...ATTRIBUTE_NAMES], + childList: true, + subtree: true, + }); +}