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.
244 lines
9.5 KiB
TypeScript
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')
|
|
})
|
|
})
|