mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
feat(ime): add shared Enter-ownership seams for CJK composition
The confirming Enter of a CJK composition arrives as two keydowns and the orderings differ by platform: Windows/Linux redispatch the unmarked Enter/13 before keyup, macOS delivers keyup first. A guard reading only isComposing or keyCode 229 misses the redispatch, so surfaces submitted on a confirm. Adds useImeEnterGestureOwnership (carry token, next-frame expiry), a shared ImeEnterGuardedForm for native implicit submission, and the cmdk seam covering 18 CommandInput surfaces at one site. A chorded Enter arms the carry but is never swallowed — the reverse would eat a user's deliberate Cmd/Ctrl+Enter. Both failure modes are pinned by ime-enter-gesture-ownership-contract.test.ts. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { act, fireEvent } from '@testing-library/react'
|
||||
|
||||
function dispatchKey(
|
||||
input: HTMLInputElement,
|
||||
type: 'keydown' | 'keyup',
|
||||
init: KeyboardEventInit
|
||||
): boolean {
|
||||
const event = new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init })
|
||||
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
|
||||
act(() => input.dispatchEvent(event))
|
||||
return event.defaultPrevented
|
||||
}
|
||||
|
||||
function dispatchImplicitSubmit(input: HTMLInputElement, init: KeyboardEventInit): boolean {
|
||||
const prevented = dispatchKey(input, 'keydown', init)
|
||||
if (!prevented) {
|
||||
const form = input.closest('form')
|
||||
if (!form) {
|
||||
throw new Error('missing implicit-submit form')
|
||||
}
|
||||
act(() => form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })))
|
||||
}
|
||||
return prevented
|
||||
}
|
||||
|
||||
export function dispatchRecordedImeImplicitSubmit(input: HTMLInputElement): boolean {
|
||||
fireEvent.compositionStart(input)
|
||||
dispatchKey(input, 'keydown', {
|
||||
key: 'Process',
|
||||
code: 'Enter',
|
||||
keyCode: 229,
|
||||
isComposing: true
|
||||
})
|
||||
fireEvent.compositionEnd(input, { data: '가' })
|
||||
const prevented = dispatchImplicitSubmit(input, {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
keyCode: 13,
|
||||
isComposing: false
|
||||
})
|
||||
dispatchKey(input, 'keyup', { key: 'Process', code: 'Enter', keyCode: 229 })
|
||||
dispatchKey(input, 'keyup', { key: 'Enter', code: 'Enter', keyCode: 13 })
|
||||
return prevented
|
||||
}
|
||||
|
||||
export function dispatchOrdinaryImplicitSubmit(input: HTMLInputElement): boolean {
|
||||
return dispatchImplicitSubmit(input, {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
keyCode: 13,
|
||||
isComposing: false
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { dispatchOrdinaryImplicitSubmit } from './ime-enter-guarded-form.test-events'
|
||||
import { ImeEnterGuardedForm } from './ime-enter-guarded-form'
|
||||
|
||||
function dispatchKey(input: HTMLInputElement, init: KeyboardEventInit): boolean {
|
||||
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })
|
||||
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
|
||||
act(() => input.dispatchEvent(event))
|
||||
return event.defaultPrevented
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('form-level Enter default prevention', () => {
|
||||
it('allows browser implicit submission without a bubbled veto', async () => {
|
||||
const onSubmit = vi.fn((event: React.FormEvent) => event.preventDefault())
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<form onSubmit={onSubmit}>
|
||||
<input aria-label="unguarded" />
|
||||
</form>
|
||||
)
|
||||
|
||||
await user.click(screen.getByLabelText('unguarded'))
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('vetoes browser implicit submission from the bubbled form keydown', async () => {
|
||||
const onSubmit = vi.fn((event: React.FormEvent) => event.preventDefault())
|
||||
let bubbledCurrentTarget: EventTarget | null = null
|
||||
const onKeyDown = vi.fn((event: React.KeyboardEvent) => {
|
||||
bubbledCurrentTarget = event.currentTarget
|
||||
event.preventDefault()
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<form onKeyDown={onKeyDown} onSubmit={onSubmit}>
|
||||
<input aria-label="guarded" />
|
||||
</form>
|
||||
)
|
||||
|
||||
await user.click(screen.getByLabelText('guarded'))
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(onKeyDown).toHaveBeenCalledOnce()
|
||||
expect(bubbledCurrentTarget).toBeInstanceOf(HTMLFormElement)
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ImeEnterGuardedForm field ownership', () => {
|
||||
it('resets the carry when focus moves between fields', () => {
|
||||
const onSubmit = vi.fn((event: React.FormEvent) => event.preventDefault())
|
||||
render(
|
||||
<ImeEnterGuardedForm onSubmit={onSubmit}>
|
||||
<input aria-label="first" />
|
||||
<input aria-label="second" />
|
||||
</ImeEnterGuardedForm>
|
||||
)
|
||||
const first = screen.getByLabelText('first') as HTMLInputElement
|
||||
const second = screen.getByLabelText('second') as HTMLInputElement
|
||||
|
||||
fireEvent.focus(first)
|
||||
fireEvent.compositionStart(first)
|
||||
expect(
|
||||
dispatchKey(first, {
|
||||
key: 'Process',
|
||||
code: 'Enter',
|
||||
keyCode: 229,
|
||||
isComposing: true
|
||||
})
|
||||
).toBe(true)
|
||||
fireEvent.compositionEnd(first, { data: '가' })
|
||||
fireEvent.blur(first)
|
||||
fireEvent.focus(second)
|
||||
|
||||
expect(dispatchOrdinaryImplicitSubmit(second)).toBe(false)
|
||||
expect(onSubmit).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
|
||||
|
||||
export function ImeEnterGuardedForm({
|
||||
onBlur,
|
||||
onCompositionEnd,
|
||||
onCompositionStart,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
...props
|
||||
}: ComponentProps<'form'>): React.JSX.Element {
|
||||
const imeEnter = useImeEnterGestureOwnership()
|
||||
|
||||
return (
|
||||
<form
|
||||
{...props}
|
||||
onCompositionStart={(event) => {
|
||||
imeEnter.setComposing(true)
|
||||
onCompositionStart?.(event)
|
||||
}}
|
||||
onCompositionEnd={(event) => {
|
||||
imeEnter.setComposing(false)
|
||||
onCompositionEnd?.(event)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (imeEnter.ownsKeyDown(event)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
onKeyDown?.(event)
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
imeEnter.onKeyUp(event)
|
||||
onKeyUp?.(event)
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
imeEnter.reset()
|
||||
onBlur?.(event)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Command, CommandInput, CommandItem, CommandList } from './command'
|
||||
|
||||
// cmdk puts its Enter->select dispatch on the Command root and guards it with
|
||||
// only `isComposing || keyCode === 229`. macOS redispatches the Enter that
|
||||
// confirms a CJK composition as an unmarked Enter/13, which that guard lets
|
||||
// through. These pin the veto our root handler adds — the guard itself lives in
|
||||
// a dependency and cannot be fixed at the source.
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: ReturnType<typeof createRoot>
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
})
|
||||
|
||||
function render(onSelect: () => void): HTMLInputElement {
|
||||
act(() => {
|
||||
root.render(
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput placeholder="Search templates..." />
|
||||
<CommandList>
|
||||
<CommandItem value="가나다 template" onSelect={onSelect}>
|
||||
template
|
||||
</CommandItem>
|
||||
</CommandList>
|
||||
</Command>
|
||||
)
|
||||
})
|
||||
return container.querySelector('input')!
|
||||
}
|
||||
|
||||
function key(input: HTMLInputElement, type: 'keydown' | 'keyup', init: KeyboardEventInit): void {
|
||||
const event = new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init })
|
||||
Object.defineProperty(event, 'keyCode', { value: init.keyCode })
|
||||
Object.defineProperty(event, 'isComposing', { value: init.isComposing === true })
|
||||
act(() => input.dispatchEvent(event))
|
||||
}
|
||||
|
||||
function composition(
|
||||
input: HTMLInputElement,
|
||||
type: 'compositionstart' | 'compositionend',
|
||||
data = ''
|
||||
) {
|
||||
act(() => input.dispatchEvent(new CompositionEvent(type, { bubbles: true, data })))
|
||||
}
|
||||
|
||||
// The carry expires on the NEXT FRAME, never synchronously — macOS delivers keyup
|
||||
// before its unmarked redispatch. A human pressing a later deliberate Enter is many
|
||||
// frames away, so advance one.
|
||||
function advanceFrame(run: () => void): void {
|
||||
let frame: FrameRequestCallback | undefined
|
||||
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
frame = cb
|
||||
return 1
|
||||
})
|
||||
run()
|
||||
act(() => frame?.(0))
|
||||
raf.mockRestore()
|
||||
}
|
||||
|
||||
describe('Command IME Enter ownership', () => {
|
||||
it('selects the highlighted item on an ordinary Enter', () => {
|
||||
const onSelect = vi.fn()
|
||||
const input = render(onSelect)
|
||||
|
||||
key(input, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 })
|
||||
|
||||
expect(onSelect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// Recorded macOS 2-Set Korean shape: the confirming Enter arrives twice, and the
|
||||
// second one is unmarked. Before the veto this created a file the user never asked
|
||||
// for while their search text was still mid-word.
|
||||
it('does not select on the unmarked Enter that macOS redispatches after compositionend', () => {
|
||||
const onSelect = vi.fn()
|
||||
const input = render(onSelect)
|
||||
|
||||
composition(input, 'compositionstart')
|
||||
key(input, 'keydown', { key: 'Process', code: 'Enter', keyCode: 229, isComposing: true })
|
||||
composition(input, 'compositionend', '가')
|
||||
key(input, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13, isComposing: false })
|
||||
key(input, 'keyup', { key: 'Enter', code: 'Enter', keyCode: 13 })
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('selects on the deliberate Enter that follows a confirmed composition', () => {
|
||||
const onSelect = vi.fn()
|
||||
const input = render(onSelect)
|
||||
|
||||
composition(input, 'compositionstart')
|
||||
key(input, 'keydown', { key: 'Process', code: 'Enter', keyCode: 229, isComposing: true })
|
||||
composition(input, 'compositionend', '가')
|
||||
key(input, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13, isComposing: false })
|
||||
advanceFrame(() => key(input, 'keyup', { key: 'Enter', code: 'Enter', keyCode: 13 }))
|
||||
|
||||
key(input, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13, isComposing: false })
|
||||
|
||||
expect(onSelect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// Windows/Linux redispatch the unmarked Enter BEFORE keyup, so the carry must not
|
||||
// depend on the macOS ordering.
|
||||
it('does not select when the redispatch precedes keyup', () => {
|
||||
const onSelect = vi.fn()
|
||||
const input = render(onSelect)
|
||||
|
||||
composition(input, 'compositionstart')
|
||||
key(input, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 229, isComposing: true })
|
||||
key(input, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13, isComposing: false })
|
||||
composition(input, 'compositionend', '가')
|
||||
key(input, 'keyup', { key: 'Enter', code: 'Enter', keyCode: 13 })
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still forwards a consumer onKeyDown for keys the IME does not own', () => {
|
||||
const onKeyDown = vi.fn()
|
||||
act(() => {
|
||||
root.render(
|
||||
<Command shouldFilter={false} onKeyDown={onKeyDown}>
|
||||
<CommandInput />
|
||||
<CommandList>
|
||||
<CommandItem value="a">a</CommandItem>
|
||||
</CommandList>
|
||||
</Command>
|
||||
)
|
||||
})
|
||||
const input = container.querySelector('input')!
|
||||
|
||||
key(input, 'keydown', { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 })
|
||||
|
||||
expect(onKeyDown).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -5,12 +5,57 @@ import { Command as CommandPrimitive } from 'cmdk'
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
|
||||
import {
|
||||
isImeOwnedKeyboardEvent,
|
||||
useImeEnterGestureOwnership
|
||||
} from '@/lib/ime-composition-keyboard-event'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
/**
|
||||
* Why: cmdk owns the Enter->select dispatch on this root div, guarded by only
|
||||
* `isComposing || keyCode === 229`. macOS redispatches the Enter that merely
|
||||
* confirms a CJK composition as an unmarked `Enter`/13, which sails past that
|
||||
* guard and selects whatever row is highlighted while the user is still
|
||||
* mid-word. The guard lives in a dependency, but cmdk calls this handler before
|
||||
* its own switch and skips on `defaultPrevented`, so vetoing here is the single
|
||||
* seam that covers every CommandInput surface.
|
||||
*/
|
||||
function Command({
|
||||
className,
|
||||
onCompositionEnd,
|
||||
onCompositionStart,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
const imeEnter = useImeEnterGestureOwnership()
|
||||
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
onKeyDown={(event) => {
|
||||
if (imeEnter.ownsKeyDown(event)) {
|
||||
// The hook cancels the carried redispatch itself; only an Enter marked
|
||||
// by composition state alone still needs the veto cmdk would miss.
|
||||
if (!isImeOwnedKeyboardEvent(event)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
onKeyDown?.(event)
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
imeEnter.onKeyUp(event)
|
||||
onKeyUp?.(event)
|
||||
}}
|
||||
onCompositionStart={(event) => {
|
||||
imeEnter.setComposing(true)
|
||||
onCompositionStart?.(event)
|
||||
}}
|
||||
onCompositionEnd={(event) => {
|
||||
imeEnter.setComposing(false)
|
||||
onCompositionEnd?.(event)
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||
className
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
|
||||
import { useMemo, useRef, type KeyboardEvent as ReactKeyboardEvent } from 'react'
|
||||
|
||||
type ImeKeyboardEvent = {
|
||||
isComposing?: boolean
|
||||
@@ -44,6 +44,99 @@ export function isMarkedImeOwnedShortcutEvent(event: object): boolean {
|
||||
return (event as { [IME_OWNED_SHORTCUT_EVENT]?: boolean })[IME_OWNED_SHORTCUT_EVENT] === true
|
||||
}
|
||||
|
||||
type ImeEnterGestureEvent = Pick<
|
||||
ReactKeyboardEvent,
|
||||
'key' | 'keyCode' | 'nativeEvent' | 'preventDefault' | 'shiftKey'
|
||||
> & { altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean }
|
||||
|
||||
/**
|
||||
* Why: the confirming Enter of a CJK composition arrives as two keydowns, and the
|
||||
* two orderings differ by platform. Windows/Linux redispatch the unmarked
|
||||
* `Enter`/13 *before* keyup; macOS delivers keyup first and redispatches after.
|
||||
* A token that expires synchronously on keyup therefore regresses macOS, so the
|
||||
* carry survives until the next animation frame. Identity-scoped so an older
|
||||
* gesture's expiry cannot clear a newer one.
|
||||
*/
|
||||
export function useImeEnterGestureOwnership(): {
|
||||
isComposing: () => boolean
|
||||
ownsKeyDown: (event: ImeEnterGestureEvent) => boolean
|
||||
onKeyUp: (event: ImeEnterGestureEvent) => void
|
||||
reset: () => void
|
||||
setComposing: (active: boolean) => void
|
||||
} {
|
||||
const stateRef = useRef<{ composing: boolean; pendingEnter: object | null }>({
|
||||
composing: false,
|
||||
pendingEnter: null
|
||||
})
|
||||
|
||||
return useMemo(() => {
|
||||
const reset = (): void => {
|
||||
stateRef.current = { composing: false, pendingEnter: null }
|
||||
}
|
||||
// Shift+Enter is a newline, never a submit — it must never be owned or swallowed.
|
||||
const isPlainEnter = (event: ImeEnterGestureEvent): boolean =>
|
||||
event.key === 'Enter' && event.keyCode === 13 && !event.shiftKey
|
||||
// The redispatched Enter of a confirm carries no modifiers, so a chorded one is the
|
||||
// user's own submit aimed past the IME. It must still ARM, and must never be swallowed.
|
||||
const hasChordModifier = (event: ImeEnterGestureEvent): boolean =>
|
||||
Boolean(event.altKey || event.ctrlKey || event.metaKey)
|
||||
return {
|
||||
isComposing: () => stateRef.current.composing,
|
||||
ownsKeyDown: (event: ImeEnterGestureEvent): boolean => {
|
||||
const markedEnter =
|
||||
(event.nativeEvent.isComposing || stateRef.current.composing) &&
|
||||
(isPlainEnter(event) ||
|
||||
(event.key === 'Enter' && event.keyCode === 229) ||
|
||||
(event.key === 'Process' && event.keyCode === 229))
|
||||
if (markedEnter) {
|
||||
stateRef.current.pendingEnter = {}
|
||||
return true
|
||||
}
|
||||
if (
|
||||
stateRef.current.pendingEnter &&
|
||||
isPlainEnter(event) &&
|
||||
!event.nativeEvent.isComposing
|
||||
) {
|
||||
// The gesture resolves either way, so the carry is spent either way; only a bare
|
||||
// Enter is also swallowed, because a chorded one is the user's own submit.
|
||||
stateRef.current.pendingEnter = null
|
||||
if (hasChordModifier(event)) {
|
||||
return false
|
||||
}
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
onKeyUp: (event: ImeEnterGestureEvent): void => {
|
||||
// A Process/229 keyup means the IME finished without redispatching, so the
|
||||
// gesture is over immediately.
|
||||
if (event.key === 'Process' && event.keyCode === 229) {
|
||||
stateRef.current.pendingEnter = null
|
||||
return
|
||||
}
|
||||
// Every other keyup expires on the NEXT FRAME, never synchronously. Enter/13
|
||||
// because macOS delivers keyup before the unmarked redispatch; anything else
|
||||
// because IMEs reporting Process/229 on every key (Pinyin candidate selection)
|
||||
// release a non-Enter key, and a Process-only clear left the carry armed and ate
|
||||
// the user's next real Enter.
|
||||
const pendingEnter = stateRef.current.pendingEnter
|
||||
if (pendingEnter) {
|
||||
requestAnimationFrame(() => {
|
||||
if (stateRef.current.pendingEnter === pendingEnter) {
|
||||
stateRef.current.pendingEnter = null
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
reset,
|
||||
setComposing: (active: boolean) => {
|
||||
stateRef.current.composing = active
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: CJK IMEs (Japanese/Chinese/Korean) fire a keydown for the Enter that
|
||||
* only confirms a conversion candidate. Rename/title inputs that commit on
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
|
||||
import { useImeEnterGestureOwnership } from './ime-composition-keyboard-event'
|
||||
|
||||
/**
|
||||
* The ownership contract for the confirming Enter of a CJK composition. Every case below is
|
||||
* load-bearing; two of them describe bugs that were live on the same day, in opposite
|
||||
* directions, and both are easy to reintroduce while "simplifying" the consume branch:
|
||||
*
|
||||
* - Drop `!hasChordModifier` from the consume branch and a chorded confirm is SWALLOWED —
|
||||
* the user's Ctrl/Cmd+Enter submit silently does nothing.
|
||||
* - Clear the carry only on the bare path and the chorded confirm leaves it ARMED —
|
||||
* the user's next Enter is eaten instead.
|
||||
*
|
||||
* The carry must be spent on both paths; only a bare Enter is also consumed.
|
||||
*/
|
||||
|
||||
type Chord = { altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }
|
||||
type TestKeyEvent = ReactKeyboardEvent & { prevented: boolean }
|
||||
|
||||
function enter(
|
||||
opts: { key?: string; keyCode?: number; isComposing?: boolean } & Chord = {}
|
||||
): TestKeyEvent {
|
||||
return {
|
||||
key: opts.key ?? 'Enter',
|
||||
keyCode: opts.keyCode ?? 13,
|
||||
altKey: opts.altKey ?? false,
|
||||
ctrlKey: opts.ctrlKey ?? false,
|
||||
metaKey: opts.metaKey ?? false,
|
||||
shiftKey: opts.shiftKey ?? false,
|
||||
nativeEvent: { isComposing: opts.isComposing ?? false },
|
||||
prevented: false,
|
||||
preventDefault() {
|
||||
;(this as { prevented: boolean }).prevented = true
|
||||
}
|
||||
} as unknown as TestKeyEvent
|
||||
}
|
||||
|
||||
function ownership() {
|
||||
return renderHook(() => useImeEnterGestureOwnership()).result
|
||||
}
|
||||
|
||||
/** Windows/Linux ordering: the IME redispatches the unmarked Enter before any keyup. */
|
||||
function confirmGesture(result: ReturnType<typeof ownership>, redispatch: TestKeyEvent): boolean {
|
||||
result.current.setComposing(true)
|
||||
result.current.ownsKeyDown(enter({ key: 'Process', keyCode: 229, isComposing: true }))
|
||||
result.current.setComposing(false)
|
||||
return result.current.ownsKeyDown(redispatch)
|
||||
}
|
||||
|
||||
const CHORDS: Chord[] = [{ ctrlKey: true }, { altKey: true }, { metaKey: true }]
|
||||
|
||||
describe('IME Enter gesture ownership — the four behaviours', () => {
|
||||
it('blocks the unmarked Enter redispatched after compositionend', () => {
|
||||
const result = ownership()
|
||||
const redispatch = enter()
|
||||
expect(confirmGesture(result, redispatch)).toBe(true)
|
||||
expect(redispatch.prevented).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks a chord pressed during composition, so the preedit survives', () => {
|
||||
for (const chord of CHORDS) {
|
||||
const result = ownership()
|
||||
result.current.setComposing(true)
|
||||
expect(result.current.ownsKeyDown(enter({ isComposing: true, ...chord }))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('submits when a modifier is held through the confirm', () => {
|
||||
for (const chord of CHORDS) {
|
||||
const result = ownership()
|
||||
const redispatch = enter(chord)
|
||||
expect(confirmGesture(result, redispatch)).toBe(false)
|
||||
expect(redispatch.prevented).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('submits an ordinary Enter with no composition in flight', () => {
|
||||
const result = ownership()
|
||||
const ordinary = enter()
|
||||
expect(result.current.ownsKeyDown(ordinary)).toBe(false)
|
||||
expect(ordinary.prevented).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('IME Enter gesture ownership — the carry is spent on both paths', () => {
|
||||
it('does not eat the next Enter after a chorded confirm passed through', () => {
|
||||
const result = ownership()
|
||||
expect(confirmGesture(result, enter({ ctrlKey: true }))).toBe(false)
|
||||
const next = enter()
|
||||
expect({ owned: result.current.ownsKeyDown(next), prevented: next.prevented }).toEqual({
|
||||
owned: false,
|
||||
prevented: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not eat the next Enter even within the chord keyup frame', () => {
|
||||
const result = ownership()
|
||||
expect(confirmGesture(result, enter({ ctrlKey: true }))).toBe(false)
|
||||
// The chord's own keyup only schedules the next-frame expiry, so an Enter landing
|
||||
// before that frame turns must still reach the app.
|
||||
result.current.onKeyUp(enter({ ctrlKey: true }))
|
||||
const next = enter()
|
||||
expect({ owned: result.current.ownsKeyDown(next), prevented: next.prevented }).toEqual({
|
||||
owned: false,
|
||||
prevented: false
|
||||
})
|
||||
})
|
||||
|
||||
it('spends the carry on a bare confirm too, so the following Enter is free', () => {
|
||||
const result = ownership()
|
||||
expect(confirmGesture(result, enter())).toBe(true)
|
||||
expect(result.current.ownsKeyDown(enter())).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('IME Enter gesture ownership — Shift+Enter is always a newline', () => {
|
||||
it('never owns Shift+Enter, composing or on the redispatch', () => {
|
||||
const result = ownership()
|
||||
result.current.setComposing(true)
|
||||
expect(result.current.ownsKeyDown(enter({ isComposing: true, shiftKey: true }))).toBe(false)
|
||||
result.current.setComposing(false)
|
||||
const redispatch = enter({ shiftKey: true })
|
||||
expect(result.current.ownsKeyDown(redispatch)).toBe(false)
|
||||
expect(redispatch.prevented).toBe(false)
|
||||
})
|
||||
|
||||
it('never owns Shift+Enter even while a real confirm is armed', () => {
|
||||
const result = ownership()
|
||||
expect(confirmGesture(result, enter({ shiftKey: true }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('IME Enter gesture ownership — expiry timing', () => {
|
||||
it('outlives a keyup delivered before the redispatch, as macOS does', () => {
|
||||
const result = ownership()
|
||||
result.current.setComposing(true)
|
||||
result.current.ownsKeyDown(enter({ key: 'Process', keyCode: 229, isComposing: true }))
|
||||
result.current.setComposing(false)
|
||||
// A synchronous clear here sends the composed text one keystroke early on macOS.
|
||||
result.current.onKeyUp(enter())
|
||||
const redispatch = enter()
|
||||
expect(result.current.ownsKeyDown(redispatch)).toBe(true)
|
||||
expect(redispatch.prevented).toBe(true)
|
||||
})
|
||||
|
||||
it('expires on the next frame rather than synchronously', async () => {
|
||||
const result = ownership()
|
||||
result.current.setComposing(true)
|
||||
result.current.ownsKeyDown(enter({ key: 'Process', keyCode: 229, isComposing: true }))
|
||||
result.current.setComposing(false)
|
||||
result.current.onKeyUp(enter())
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
expect(result.current.ownsKeyDown(enter())).toBe(false)
|
||||
})
|
||||
|
||||
it('clears immediately on a Process/229 keyup, which means the IME finished', () => {
|
||||
const result = ownership()
|
||||
result.current.setComposing(true)
|
||||
result.current.ownsKeyDown(enter({ key: 'Process', keyCode: 229, isComposing: true }))
|
||||
result.current.setComposing(false)
|
||||
result.current.onKeyUp(enter({ key: 'Process', keyCode: 229 }))
|
||||
expect(result.current.ownsKeyDown(enter())).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user