diff --git a/.changeset/three-oranges-brake.md b/.changeset/three-oranges-brake.md new file mode 100644 index 0000000..78af945 --- /dev/null +++ b/.changeset/three-oranges-brake.md @@ -0,0 +1,5 @@ +--- +"@primer/live-region-element": minor +--- + +Update logic for finding, or creating, live regions to work while within dialog elements diff --git a/packages/live-region-element/src/__tests__/query.test.ts b/packages/live-region-element/src/__tests__/query.test.ts new file mode 100644 index 0000000..b6da9ad --- /dev/null +++ b/packages/live-region-element/src/__tests__/query.test.ts @@ -0,0 +1,99 @@ +import {describe, test, expect, afterEach} from 'vitest' +import '../define' +import {findOrCreateLiveRegion, getClosestLiveRegion} from '../query' + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('findOrCreateLiveRegion', () => { + test('no live region', () => { + expect(document.querySelector('live-region')).toBe(null) + const liveRegion = findOrCreateLiveRegion() + expect(liveRegion).toBeInTheDocument() + expect(document.querySelector('live-region')).toBe(liveRegion) + }) + + test('existing live region', () => { + const liveRegion = document.createElement('live-region') + document.body.appendChild(liveRegion) + expect(findOrCreateLiveRegion()).toBe(liveRegion) + }) + + test('in dialog with live region', () => { + document.body.innerHTML = ` + +
+ +
+ + ` + const liveRegion = findOrCreateLiveRegion(document.getElementById('target')!) + expect(liveRegion).toBe(document.getElementById('local')) + }) + + test('in dialog with no live region', () => { + document.body.innerHTML = ` + +
+
+ + ` + const dialog = document.querySelector('dialog')! + expect(dialog.querySelector('live-region')).toBe(null) + + const liveRegion = findOrCreateLiveRegion(document.getElementById('target')!) + expect(dialog).toContainElement(liveRegion) + }) +}) + +describe('getClosestLiveRegion', () => { + test('no live region', () => { + const element = document.createElement('div') + document.body.appendChild(element) + expect(getClosestLiveRegion(element)).toBe(null) + }) + + test('live region in document.body', () => { + const element = document.createElement('div') + document.body.appendChild(element) + + const liveRegion = document.createElement('live-region') + document.body.appendChild(liveRegion) + + expect(getClosestLiveRegion(element)).toBe(liveRegion) + }) + + test('live region as sibling', () => { + document.body.innerHTML = ` +
+
+ +
+ + ` + + expect(getClosestLiveRegion(document.getElementById('target')!)).toBe(document.getElementById('sibling')) + }) + + test('live region within dialog', () => { + document.body.innerHTML = ` + +
+ +
+ + ` + expect(getClosestLiveRegion(document.getElementById('target')!)).toBe(document.getElementById('local')) + }) + + test('no live region within dialog', () => { + document.body.innerHTML = ` + +
+
+ + ` + expect(getClosestLiveRegion(document.getElementById('target')!)).toBe(null) + }) +}) diff --git a/packages/live-region-element/src/index.ts b/packages/live-region-element/src/index.ts index 6759452..36750b9 100644 --- a/packages/live-region-element/src/index.ts +++ b/packages/live-region-element/src/index.ts @@ -1,5 +1,6 @@ import './define' import {LiveRegionElement, templateContent, type AnnounceOptions} from './live-region-element' +import {findOrCreateLiveRegion} from './query' type GlobalAnnounceOptions = AnnounceOptions & { /** @@ -32,52 +33,4 @@ export function announceFromElement(element: HTMLElement, options: GlobalAnnounc return liveRegion.announceFromElement(element, options) } -let liveRegion: LiveRegionElement | null = null - -function findOrCreateLiveRegion(from?: HTMLElement, appendTo?: HTMLElement): LiveRegionElement { - // Check to see if we have already created a `` element and that - // it is currently connected to the DOM. - if (liveRegion !== null && liveRegion.isConnected) { - return liveRegion - } - - // If `from` is defined, try to find the closest `` element - // relative to the given element - liveRegion = from ? getClosestLiveRegion(from) : null - if (liveRegion !== null) { - return liveRegion - } - - // Otherwise, try to find any `` element in the document - liveRegion = document.querySelector('live-region') - if (liveRegion !== null) { - return liveRegion - } - - // Finally, if none exist, create a new `` element and append it - // to the given `appendTo` element, if one exists - liveRegion = document.createElement('live-region') as LiveRegionElement - if (appendTo) { - appendTo.appendChild(liveRegion) - } else { - document.body.appendChild(liveRegion) - } - - return liveRegion -} - -function getClosestLiveRegion(from: HTMLElement): LiveRegionElement | null { - let current: HTMLElement | null = from - - while ((current = current.parentElement)) { - for (const child of current.childNodes) { - if (child instanceof LiveRegionElement) { - return child - } - } - } - - return null -} - export {LiveRegionElement, templateContent} diff --git a/packages/live-region-element/src/query.ts b/packages/live-region-element/src/query.ts new file mode 100644 index 0000000..69b7d65 --- /dev/null +++ b/packages/live-region-element/src/query.ts @@ -0,0 +1,60 @@ +import {LiveRegionElement} from './live-region-element' + +export function findOrCreateLiveRegion(from?: HTMLElement, appendTo?: HTMLElement): LiveRegionElement { + let liveRegion: LiveRegionElement | null = null + + // If `from` is defined, try to find the closest `` element + // relative to the given element + liveRegion = from ? getClosestLiveRegion(from) : null + if (liveRegion !== null) { + return liveRegion + } + + // Get the containing element for the live region. If we know we are within a + // dialog element, then live regions must live within that element. + let container = document.body + if (from) { + const dialog = from.closest('dialog') + if (dialog) { + container = dialog + } + } + + // Otherwise, try to find any `` element in the document + liveRegion = container.querySelector('live-region') + if (liveRegion !== null) { + return liveRegion + } + + // Finally, if none exist, create a new `` element and append it + // to the given `appendTo` element, if one exists + liveRegion = document.createElement('live-region') + if (appendTo) { + appendTo.appendChild(liveRegion) + } else { + container.appendChild(liveRegion) + } + + return liveRegion +} + +export function getClosestLiveRegion(from: HTMLElement): LiveRegionElement | null { + const dialog = from.closest('dialog') + let current: HTMLElement | null = from + + while ((current = current.parentElement)) { + // If the element exists within a , we can only use a live region + // within that element + if (dialog && !dialog.contains(current)) { + break + } + + for (const child of current.childNodes) { + if (child instanceof LiveRegionElement) { + return child + } + } + } + + return null +}