fix(19.7): RB-WR-01 save shortcuts commit a draft inside a field type's shadow root
The save shortcuts blurred document.activeElement before clicking. With focus on an input inside a shadow root that is the host, and blur() on a host that is not itself focused does nothing (spec, Firefox), so a plugin field type committing on the native change event lost the typed value. The shortcut now focuses the button first, as a pointer press does, which unfocuses the inner input in open and closed roots, and gives focus back to the innermost element reached through open shadow roots.
This commit is contained in:
@@ -221,16 +221,41 @@ function onModifierKey(event: KeyboardEvent): void {
|
||||
if (event.repeat || isDisabled(target)) {
|
||||
return
|
||||
}
|
||||
// Commit pending edits the way a pointer click would: blurring the
|
||||
// focused field runs its blur and native change handlers (a taglist's
|
||||
// typed-but-uncommitted tag) before the button reads the values.
|
||||
const active = doc.activeElement
|
||||
const blurred = active instanceof HTMLElement && active !== target && active !== doc.body ? active : null
|
||||
blurred?.blur()
|
||||
target.click()
|
||||
if (blurred?.isConnected) {
|
||||
blurred.focus()
|
||||
// Commit pending edits the way a pointer click would: the press focuses
|
||||
// the button, which unfocuses the field and runs its blur and native change
|
||||
// handlers (a taglist's typed-but-uncommitted tag, a plugin field type that
|
||||
// emits summer-change on change) before the button reads the values.
|
||||
// Moving focus reaches an input inside a shadow root, open or closed;
|
||||
// blur() on the shadow host does not (document.activeElement is the host).
|
||||
const previous = deepestActiveElement(doc)
|
||||
const restore = previous instanceof HTMLElement && previous !== target && previous !== doc.body ? previous : null
|
||||
if (restore) {
|
||||
target.focus({ preventScroll: true })
|
||||
if (deepestActiveElement(doc) === restore) {
|
||||
// The target takes no focus (not a focusable element): unfocus the
|
||||
// field itself, which works once we hold the innermost element.
|
||||
restore.blur()
|
||||
}
|
||||
}
|
||||
target.click()
|
||||
// Give focus back unless the click moved it somewhere else on purpose.
|
||||
const after = doc.activeElement
|
||||
if (restore?.isConnected && (after === target || after === doc.body || after === null)) {
|
||||
restore.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The focused element, followed through open shadow roots
|
||||
* (document.activeElement stops at the shadow host). A closed root stops the
|
||||
* walk at its host.
|
||||
*/
|
||||
function deepestActiveElement(doc: Document = document): Element | null {
|
||||
let active: Element | null = doc.activeElement
|
||||
while (active?.shadowRoot?.activeElement) {
|
||||
active = active.shadowRoot.activeElement
|
||||
}
|
||||
return active
|
||||
}
|
||||
|
||||
function onPlainKey(event: KeyboardEvent): void {
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
// keys never fire from an input inside an open shadow root or from a custom
|
||||
// field element inside a form (a closed root hides its input), and the save
|
||||
// shortcuts commit a taglist's typed-but-uncommitted tag before saving, the
|
||||
// way a pointer click on the button does. No application names appear in
|
||||
// framework tests.
|
||||
// way a pointer click on the button does. RB-WR-01: the same holds for an
|
||||
// input inside a plugin field type's shadow root, open or closed, where
|
||||
// document.activeElement is the host and blur() on the host does nothing. No
|
||||
// application names appear in framework tests.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
|
||||
import { closeShortcutHelp, installHotkeys, shortcutHelp } from '../../src/app/hotkeys'
|
||||
@@ -122,6 +124,66 @@ describe('plain keys from editable controls hidden in shadow DOM', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// A plugin field type that commits on its inner input's native change event,
|
||||
// as PluginField's summer-change protocol allows. happy-dom fires no change
|
||||
// on blur; a browser does, so the input emulates it.
|
||||
function shadowField(mode: ShadowRootMode): { host: HTMLElement; input: HTMLInputElement } {
|
||||
const host = document.createElement('div')
|
||||
const input = document.createElement('input')
|
||||
let committed = ''
|
||||
input.addEventListener('blur', () => {
|
||||
if (input.value !== committed) {
|
||||
committed = input.value
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
})
|
||||
input.addEventListener('change', () => {
|
||||
host.dispatchEvent(new CustomEvent('summer-change', { bubbles: true, composed: true, detail: { value: input.value } }))
|
||||
})
|
||||
host.attachShadow({ mode }).appendChild(input)
|
||||
return { host, input }
|
||||
}
|
||||
|
||||
describe('save shortcuts commit a draft inside a shadow root', () => {
|
||||
for (const mode of ['open', 'closed'] as const) {
|
||||
for (const [name, key, init] of [
|
||||
['Ctrl+S', 's', { ctrlKey: true }],
|
||||
['Ctrl+Enter', 'Enter', { ctrlKey: true }],
|
||||
] as const) {
|
||||
it(`${name} commits the typed value of an input in a ${mode} root before the click`, () => {
|
||||
uninstalls.push(installHotkeys())
|
||||
const form = document.createElement('form')
|
||||
const { host, input } = shadowField(mode)
|
||||
form.appendChild(host)
|
||||
document.body.appendChild(form)
|
||||
let value: unknown = 'initial'
|
||||
host.addEventListener('summer-change', (event) => {
|
||||
value = (event as CustomEvent<{ value: unknown }>).detail.value
|
||||
})
|
||||
const saved: unknown[] = []
|
||||
const save = document.createElement('button')
|
||||
save.type = 'button'
|
||||
save.setAttribute('data-hotkey', 'mod+s mod+enter')
|
||||
save.addEventListener('click', () => saved.push(value))
|
||||
document.body.appendChild(save)
|
||||
|
||||
input.focus()
|
||||
input.value = 'draft'
|
||||
expect(document.activeElement).toBe(host)
|
||||
|
||||
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, composed: true, ...init })
|
||||
input.dispatchEvent(event)
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(saved).toEqual(['draft'])
|
||||
if (mode === 'open') {
|
||||
// Focus goes back to the input the admin was typing in.
|
||||
expect(host.shadowRoot!.activeElement).toBe(input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('save shortcuts commit a taglist draft', () => {
|
||||
const BASE = `${API}/acme/demo/widgets`
|
||||
const RECORD = `${BASE}/1`
|
||||
|
||||
Reference in New Issue
Block a user