Files
summercms/admin/tests/app/hotkeysEditing.test.ts
Jakub Zych 3afe9fa19c 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.
2026-10-23 20:27:47 +02:00

244 lines
9.5 KiB
TypeScript

// Shortcuts while editing (phase 19.7 plan 07, B-WR-01 and B-WR-02): plain
// 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. 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'
import { setBundle } from '../../src/app/i18n'
import { clearUser } from '../../src/state/useAuth'
import { setNavigation } from '../../src/state/useNavigation'
import { setSettings } from '../../src/state/useSettings'
import { clearToasts } from '../../src/state/useToasts'
import lang from '../fixtures/lang.json'
import formSchema from '../fixtures/widgets.form-schema.json'
import record from '../fixtures/widgets.record.json'
import listSchema from '../fixtures/widgets.list-schema.json'
import listRows from '../fixtures/widgets.list.json'
import { API, mountApp, press, requestsTo, wait, type Route } from '../helpers'
let uninstalls: Array<() => void> = []
beforeEach(() => {
clearUser()
setNavigation([])
setSettings([])
clearToasts()
setBundle(lang.data, 'pl')
vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Linux x86_64')
})
afterEach(() => {
for (const uninstall of uninstalls) {
uninstall()
}
uninstalls = []
closeShortcutHelp()
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
function hotkeyButton(hotkey: string): { element: HTMLButtonElement; clicks: () => number } {
const element = document.createElement('button')
element.setAttribute('data-hotkey', hotkey)
let count = 0
element.addEventListener('click', () => count++)
document.body.appendChild(element)
return { element, clicks: () => count }
}
function composedKey(target: EventTarget, key: string): KeyboardEvent {
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, composed: true })
target.dispatchEvent(event)
return event
}
class ClosedField extends HTMLElement {
constructor() {
super()
const root = this.attachShadow({ mode: 'closed' })
root.appendChild(document.createElement('input'))
}
}
if (!customElements.get('acme-closed-field')) {
customElements.define('acme-closed-field', ClosedField)
}
describe('plain keys from editable controls hidden in shadow DOM', () => {
it('ignores ? and Escape from an input inside an open shadow root', () => {
uninstalls.push(installHotkeys())
const leave = hotkeyButton('escape')
const host = document.createElement('div')
document.body.appendChild(host)
const input = document.createElement('input')
host.attachShadow({ mode: 'open' }).appendChild(input)
const escape = composedKey(input, 'Escape')
const help = composedKey(input, '?')
expect(leave.clicks()).toBe(0)
expect(escape.defaultPrevented).toBe(false)
expect(help.defaultPrevented).toBe(false)
expect(shortcutHelp.value).toBeNull()
// A browser retargets the event to the shadow host by the time it
// reaches window; only composedPath() still starts at the input.
for (const key of ['Escape', '?']) {
const retargeted = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, composed: true })
Object.defineProperty(retargeted, 'composedPath', { value: () => [input, host.shadowRoot, host, document.body, document, window] })
host.dispatchEvent(retargeted)
expect(retargeted.defaultPrevented).toBe(false)
}
expect(leave.clicks()).toBe(0)
expect(shortcutHelp.value).toBeNull()
// The same keys from the page itself still work.
composedKey(document.body, 'Escape')
expect(leave.clicks()).toBe(1)
})
it('ignores ? and Escape from a custom field element inside a form (closed shadow root)', () => {
uninstalls.push(installHotkeys())
const leave = hotkeyButton('escape')
const form = document.createElement('form')
const field = document.createElement('acme-closed-field')
form.appendChild(field)
document.body.appendChild(form)
const escape = composedKey(field, 'Escape')
const help = composedKey(field, '?')
expect(leave.clicks()).toBe(0)
expect(escape.defaultPrevented).toBe(false)
expect(help.defaultPrevented).toBe(false)
expect(shortcutHelp.value).toBeNull()
// Outside a form a custom element is not an editable control.
const loose = document.createElement('acme-closed-field')
document.body.appendChild(loose)
composedKey(loose, 'Escape')
expect(leave.clicks()).toBe(1)
})
})
// 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`
const schema = structuredClone(formSchema)
schema.data.fields.push({ name: 'labels', label: 'Labels', type: 'taglist', span: 'full', mode: 'array' } as never)
const loaded = structuredClone(record)
;(loaded.data as Record<string, unknown>).labels = ['first']
const routes: Record<string, Route> = {
[`GET ${BASE}/schema/form`]: { body: schema },
[`GET ${RECORD}`]: { body: loaded },
[`PUT ${RECORD}`]: { body: loaded },
[`GET ${BASE}/schema/list`]: { body: listSchema },
[`GET ${BASE}`]: { body: listRows },
}
async function typeDraft(): Promise<HTMLInputElement> {
const input = document.querySelector<HTMLInputElement>('#field-labels')
expect(input).not.toBeNull()
input!.focus()
input!.value = 'draft'
input!.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
return input!
}
async function putBody(calls: Request[]): Promise<Record<string, unknown>> {
const puts = requestsTo(calls, 'PUT', RECORD)
expect(puts).toHaveLength(1)
return (await puts[0]!.clone().json()) as Record<string, unknown>
}
it('Ctrl+S saves the record with the draft tag', async () => {
const { calls } = await mountApp('/acme/demo/widgets/1', routes, { attach: true })
const input = await typeDraft()
expect(press(input, 's', { ctrlKey: true }).defaultPrevented).toBe(true)
await flushPromises()
await wait(0)
await flushPromises()
const body = await putBody(calls)
expect(JSON.stringify(body)).toContain('"draft"')
expect(JSON.stringify(body)).toContain('"first"')
})
it('Ctrl+Enter saves the draft tag before closing', async () => {
const { calls, router } = await mountApp('/acme/demo/widgets/1', routes, { attach: true })
const input = await typeDraft()
expect(press(input, 'Enter', { ctrlKey: true }).defaultPrevented).toBe(true)
await flushPromises()
await wait(0)
await flushPromises()
const body = await putBody(calls)
expect(JSON.stringify(body)).toContain('"draft"')
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets')
})
})