From e665514271faae5b44cf51aa88e763bee6d5204d Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Fri, 11 Sep 2026 04:21:58 +0100 Subject: [PATCH 1/3] fix: treat contenteditable="plaintext-only" as editable Browsers treat plaintext-only as an editing host. The jsdom polyfill only recognized true/empty, so type() never inserted text. --- src/utils/edit/isContentEditable.ts | 17 ++++++++++------- src/utils/focus/selector.ts | 1 + tests/utility/type.ts | 11 +++++++++++ tests/utils/edit/isContentEditable.ts | 3 ++- tests/utils/edit/isEditable.ts | 1 + 5 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/utils/edit/isContentEditable.ts b/src/utils/edit/isContentEditable.ts index e44298be..46a24dde 100644 --- a/src/utils/edit/isContentEditable.ts +++ b/src/utils/edit/isContentEditable.ts @@ -5,7 +5,8 @@ export function isContentEditable( return ( element.hasAttribute('contenteditable') && (element.getAttribute('contenteditable') == 'true' || - element.getAttribute('contenteditable') == '') + element.getAttribute('contenteditable') == '' || + element.getAttribute('contenteditable') == 'plaintext-only') ) } @@ -13,12 +14,14 @@ export function isContentEditable( * If a node is a contenteditable or inside one, return that element. */ export function getContentEditable(node: Node): Element | null { - const element = getElement(node) - return ( - element && - (element.closest('[contenteditable=""]') || - element.closest('[contenteditable="true"]')) - ) + let element = getElement(node) + while (element) { + if (isContentEditable(element)) { + return element + } + element = element.parentElement + } + return null } function getElement(node: Node) { diff --git a/src/utils/focus/selector.ts b/src/utils/focus/selector.ts index 01100baa..7914ce8b 100644 --- a/src/utils/focus/selector.ts +++ b/src/utils/focus/selector.ts @@ -5,6 +5,7 @@ export const FOCUSABLE_SELECTOR = [ 'textarea:not([disabled])', '[contenteditable=""]', '[contenteditable="true"]', + '[contenteditable="plaintext-only"]', 'a[href]', '[tabindex]:not([disabled])', 'details > summary', diff --git a/tests/utility/type.ts b/tests/utility/type.ts index 88c063fb..0ef16c9d 100644 --- a/tests/utility/type.ts +++ b/tests/utility/type.ts @@ -1,5 +1,16 @@ import {setup} from '#testHelpers' +test('type into contenteditable plaintext-only', async () => { + const {element, user} = setup( + '
foo
', + {focus: false}, + ) + + await user.type(element, 'bar') + + expect(element).toHaveTextContent('foobar') +}) + test('type into input', async () => { const {element, getEventSnapshot, user} = setup('', { focus: false, diff --git a/tests/utils/edit/isContentEditable.ts b/tests/utils/edit/isContentEditable.ts index aab44bcd..b500e746 100644 --- a/tests/utils/edit/isContentEditable.ts +++ b/tests/utils/edit/isContentEditable.ts @@ -3,11 +3,12 @@ import {isContentEditable} from '#src/utils' test('report if element is contenteditable', async () => { const {elements} = setup( - `
`, + `
`, ) expect(isContentEditable(elements[0])).toBe(false) expect(isContentEditable(elements[1])).toBe(false) expect(isContentEditable(elements[2])).toBe(true) expect(isContentEditable(elements[3])).toBe(true) + expect(isContentEditable(elements[4])).toBe(true) }) diff --git a/tests/utils/edit/isEditable.ts b/tests/utils/edit/isEditable.ts index ec8fab82..ad2f6e1c 100644 --- a/tests/utils/edit/isEditable.ts +++ b/tests/utils/edit/isEditable.ts @@ -23,6 +23,7 @@ test.each([ [``], [`
`], [`
`], + [`
`], ])('consider %s an editable element', html => { const {element} = render(html) From 95af0176a4f084315c6e1c572b50968dc9569ae4 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Mon, 14 Sep 2026 16:10:15 +0100 Subject: [PATCH 2/3] test: cover editing host resolution and paste for plaintext-only Add the nearest-editing-host cases for the ancestor walk that replaced the chained closest() calls. The contenteditable="true" case fails on the old implementation too: closest('[contenteditable=""]') matched a farther ancestor before the nearer host was considered. Add a paste test covering contenteditable="true" and "plaintext-only". --- tests/clipboard/paste.ts | 17 +++++++++++++++ tests/utils/edit/isContentEditable.ts | 31 +++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/tests/clipboard/paste.ts b/tests/clipboard/paste.ts index 9e163921..ce0d5399 100644 --- a/tests/clipboard/paste.ts +++ b/tests/clipboard/paste.ts @@ -159,3 +159,20 @@ describe('without Clipboard API', () => { expect(getEvents()).toHaveLength(0) }) }) + +test.each([['true'], ['plaintext-only']])( + 'paste into contenteditable="%s"', + async attr => { + const {element, getEvents, user} = setup( + `
foo
`, + {focus: false}, + ) + + await user.click(element) + await user.paste('bar') + + expect(element).toHaveTextContent('foobar') + expect(getEvents('paste')).toHaveLength(1) + expect(getEvents('input')).toHaveLength(1) + }, +) diff --git a/tests/utils/edit/isContentEditable.ts b/tests/utils/edit/isContentEditable.ts index b500e746..8749552a 100644 --- a/tests/utils/edit/isContentEditable.ts +++ b/tests/utils/edit/isContentEditable.ts @@ -1,5 +1,5 @@ -import {setup} from '#testHelpers' -import {isContentEditable} from '#src/utils' +import {render, setup} from '#testHelpers' +import {getContentEditable, isContentEditable} from '#src/utils' test('report if element is contenteditable', async () => { const {elements} = setup( @@ -12,3 +12,30 @@ test('report if element is contenteditable', async () => { expect(isContentEditable(elements[3])).toBe(true) expect(isContentEditable(elements[4])).toBe(true) }) + +test.each([['true'], ['plaintext-only']])( + 'resolve the nearest editing host when it is contenteditable="%s"', + async attr => { + const {element} = render( + `
foo
`, + ) + const host = element.firstElementChild + const span = element.querySelector('span') as Element + + expect(getContentEditable(span)).toBe(host) + }, +) + +test('resolve the element itself when it is the editing host', async () => { + const {element} = render(`
foo
`) + + expect(getContentEditable(element)).toBe(element) +}) + +test('resolve no editing host outside a contenteditable', async () => { + const {element} = render(`
foo
`) + + expect(getContentEditable(element.querySelector('span') as Element)).toBe( + null, + ) +}) From 1c33ed96336abbef227a7add59106507d07e9687 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Mon, 14 Sep 2026 16:26:09 +0100 Subject: [PATCH 3/3] fix: report insertLineBreak for Enter on plaintext-only A plaintext-only editing host has no paragraphs to break, so Enter is a line break. editContenteditable inserts "\n" for either inputType, so this corrects the reported type rather than the resulting content. --- src/event/behavior/keypress.ts | 5 ++++- tests/event/behavior/keypress.ts | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/event/behavior/keypress.ts b/src/event/behavior/keypress.ts index dd417ec3..c63f0969 100644 --- a/src/event/behavior/keypress.ts +++ b/src/event/behavior/keypress.ts @@ -35,7 +35,10 @@ behavior.keypress = (event, target, instance) => { if (isEditable(target)) { const inputType = event.key === 'Enter' - ? isContentEditable(target) && !instance.system.keyboard.modifiers.Shift + ? isContentEditable(target) && + // A plaintext-only host has no paragraphs to break. + target.getAttribute('contenteditable') !== 'plaintext-only' && + !instance.system.keyboard.modifiers.Shift ? 'insertParagraph' : 'insertLineBreak' : 'insertText' diff --git a/tests/event/behavior/keypress.ts b/tests/event/behavior/keypress.ts index 38d60e05..4ca49d85 100644 --- a/tests/event/behavior/keypress.ts +++ b/tests/event/behavior/keypress.ts @@ -75,6 +75,18 @@ cases( inputType: 'insertLineBreak', expectedHtml: '\n', }, + 'trigger insertLineBreak on contenteditable=plaintext-only': { + html: `
`, + inputType: 'insertLineBreak', + expectedHtml: '\n', + }, + 'with shiftKey=true trigger insertLineBreak on contenteditable=plaintext-only': + { + html: `
`, + shiftKey: true, + inputType: 'insertLineBreak', + expectedHtml: '\n', + }, }, )