diff --git a/admin/src/app/hotkeys.ts b/admin/src/app/hotkeys.ts index a29a7a1b..a4e5ee58 100644 --- a/admin/src/app/hotkeys.ts +++ b/admin/src/app/hotkeys.ts @@ -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 { diff --git a/admin/tests/app/hotkeysEditing.test.ts b/admin/tests/app/hotkeysEditing.test.ts index 725fe6fc..81032204 100644 --- a/admin/tests/app/hotkeysEditing.test.ts +++ b/admin/tests/app/hotkeysEditing.test.ts @@ -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`