diff --git a/resources/js/components/fieldtypes/bard/BardFieldtype.vue b/resources/js/components/fieldtypes/bard/BardFieldtype.vue index 0a6a6339cf5..86b8004a09e 100644 --- a/resources/js/components/fieldtypes/bard/BardFieldtype.vue +++ b/resources/js/components/fieldtypes/bard/BardFieldtype.vue @@ -166,6 +166,7 @@ import { Placeholder, Dropcursor, Gapcursor } from '@tiptap/extensions'; import SetPicker from '../replicator/SetPicker.vue'; import { DocumentBlock, DocumentInline } from './Document'; import { Set } from './Set'; +import { IgnoreAriaHidden } from './IgnoreAriaHidden'; import { Small } from './Small'; import { Image } from './Image'; import { Link } from './Link'; @@ -1111,6 +1112,7 @@ export default { Dropcursor, Gapcursor, History, + IgnoreAriaHidden, Paragraph, Set.configure({ bard: this, foo: 'bar' }), Text, diff --git a/resources/js/components/fieldtypes/bard/IgnoreAriaHidden.js b/resources/js/components/fieldtypes/bard/IgnoreAriaHidden.js new file mode 100644 index 00000000000..19cfbfab614 --- /dev/null +++ b/resources/js/components/fieldtypes/bard/IgnoreAriaHidden.js @@ -0,0 +1,28 @@ +import { Extension } from '@tiptap/core'; +import { Plugin } from '@tiptap/pm/state'; + +// Modal overlays aria-hide the page around [aria-live] and script elements, so one inside a set marks the +// editor's blocks. ProseMirror would redraw them and the sets with them; it has no public hook to prevent that. +const attributes = ['aria-hidden', 'data-aria-hidden']; + +export const IgnoreAriaHidden = Extension.create({ + name: 'ignoreAriaHidden', + + addProseMirrorPlugins() { + return [ + new Plugin({ + view({ domObserver: observer }) { + if (!observer?.registerMutation) return {}; + + const registerMutation = observer.registerMutation.bind(observer); + observer.registerMutation = (mutation, added) => + mutation.type === 'attributes' && attributes.includes(mutation.attributeName) + ? null + : registerMutation(mutation, added); + + return { destroy: () => delete observer.registerMutation }; + }, + }), + ]; + }, +}); diff --git a/resources/js/tests/components/fieldtypes/bard/BardFieldtype.test.js b/resources/js/tests/components/fieldtypes/bard/BardFieldtype.test.js index 2b7c2bcfb39..f143c44b0b5 100644 --- a/resources/js/tests/components/fieldtypes/bard/BardFieldtype.test.js +++ b/resources/js/tests/components/fieldtypes/bard/BardFieldtype.test.js @@ -1,8 +1,9 @@ -import { mount } from '@vue/test-utils'; +import { flushPromises, mount } from '@vue/test-utils'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { nextTick, ref } from 'vue'; import { Editor } from '@tiptap/vue-3'; import { closeHistory } from '@tiptap/pm/history'; +import { hideOthers } from 'aria-hidden'; import * as Globals from '@/bootstrap/globals'; import BardFieldtype from '@/components/fieldtypes/bard/BardFieldtype.vue'; import Provider from '@/components/portals/Provider.vue'; @@ -352,3 +353,27 @@ test('typing is still debounced when it does not move any sets', async () => { expect(values.value.content[0].content[0].text).toBe('One two'); }); + +test('sets are left alone when a modal overlay hides the page around a live region inside one', async () => { + const text = { type: 'paragraph', content: [{ type: 'text', text: 'Text' }] }; + const wrapper = await mountField({ sets }, [text, set('set-a', 'A'), text]); + let root = wrapper.vm.editor.view.dom; + while (root.parentElement) root = root.parentElement; + document.body.append(root); + const setElement = wrapper.find('[data-type="card"]').element; + setElement.insertAdjacentHTML('beforeend', '
'); + const overlay = document.body.appendChild(document.createElement('div')); + + const reveal = hideOthers(overlay); + await flushPromises(); + + expect(wrapper.vm.editor.view.dom.firstElementChild.getAttribute('aria-hidden')).toBe('true'); + + reveal(); + await flushPromises(); + + expect(wrapper.find('[data-type="card"]').element).toBe(setElement); + + root.remove(); + overlay.remove(); +});