// 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. 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) }) }) 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') }) })