// 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).labels = ['first'] const routes: Record = { [`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 { const input = document.querySelector('#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> { const puts = requestsTo(calls, 'PUT', RECORD) expect(puts).toHaveLength(1) return (await puts[0]!.clone().json()) as Record } 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') }) })