diff --git a/src/__tests__/keyboard/getNextKeyDef.ts b/src/__tests__/keyboard/getNextKeyDef.ts
index 7313fe5f..679be074 100644
--- a/src/__tests__/keyboard/getNextKeyDef.ts
+++ b/src/__tests__/keyboard/getNextKeyDef.ts
@@ -77,6 +77,14 @@ cases(
text: '[Control>]',
modifiers: {releaseSelf: false},
},
+ 'keep key pressed with repeatModifier': {
+ text: '{Control>2}',
+ modifiers: {releaseSelf: false},
+ },
+ 'release after repeatModifier': {
+ text: '{Control>2/}',
+ modifiers: {releaseSelf: true},
+ },
'no releaseSelf on legacy modifier': {
text: '{ctrl}',
modifiers: {releaseSelf: false},
@@ -96,15 +104,23 @@ cases(
{
'invalid descriptor': {
text: '{!}',
- expectedError: 'Expected key descriptor but found "!" in "{!}"',
+ expectedError: 'but found "!" in "{!}"',
},
'missing descriptor': {
text: '',
- expectedError: 'Expected key descriptor but found "" in ""',
+ expectedError: 'but found "" in ""',
},
'missing closing bracket': {
text: '{a)',
- expectedError: 'Expected closing bracket but found ")" in "{a)"',
+ expectedError: 'but found ")" in "{a)"',
+ },
+ 'invalid repeat modifier': {
+ text: '{a>e}',
+ expectedError: 'but found "e" in "{a>e}"',
+ },
+ 'missing bracket after repeat modifier': {
+ text: '{a>3)',
+ expectedError: 'but found ")" in "{a>3)"',
},
},
)
diff --git a/src/__tests__/keyboard/keyboardImplementation.ts b/src/__tests__/keyboard/keyboardImplementation.ts
index be9d8329..1fb9fa59 100644
--- a/src/__tests__/keyboard/keyboardImplementation.ts
+++ b/src/__tests__/keyboard/keyboardImplementation.ts
@@ -15,3 +15,122 @@ test('no character input if `altKey` or `ctrlKey` is pressed', () => {
expect(eventWasFired('keypress')).toBe(false)
expect(eventWasFired('input')).toBe(false)
})
+
+test('do not leak repeatKey in state', () => {
+ const {element} = setup(``)
+ ;(element as HTMLInputElement).focus()
+
+ const keyboardState = userEvent.keyboard('{a>2}')
+ expect(keyboardState).toHaveProperty('repeatKey', undefined)
+})
+
+describe('pressing and releasing keys', () => {
+ it('fires event with releasing key twice', () => {
+ const {element, getEventSnapshot, clearEventCalls} = setup(``)
+
+ ;(element as HTMLInputElement).focus()
+ clearEventCalls()
+
+ userEvent.keyboard('{ArrowLeft>}{ArrowLeft}')
+
+ expect(getEventSnapshot()).toMatchInlineSnapshot(`
+Events fired on: input[value=""]
+
+input[value=""] - keydown: ArrowLeft (37)
+input[value=""] - select
+input[value=""] - keyup: ArrowLeft (37)
+input[value=""] - keydown: ArrowLeft (37)
+input[value=""] - select
+input[value=""] - keyup: ArrowLeft (37)
+`)
+ })
+
+ it('fires event without releasing key', () => {
+ const {element, getEventSnapshot, clearEventCalls} = setup(``)
+
+ ;(element as HTMLInputElement).focus()
+ clearEventCalls()
+
+ userEvent.keyboard('{a>}')
+
+ expect(getEventSnapshot()).toMatchInlineSnapshot(`
+Events fired on: input[value="a"]
+
+input[value=""] - keydown: a (97)
+input[value=""] - keypress: a (97)
+input[value="a"] - input
+`)
+ })
+
+ it('fires event multiple times without releasing key', () => {
+ const {element, getEventSnapshot, clearEventCalls} = setup(``)
+ ;(element as HTMLInputElement).focus()
+ clearEventCalls()
+
+ userEvent.keyboard('{a>2}')
+
+ expect(getEventSnapshot()).toMatchInlineSnapshot(`
+Events fired on: input[value="aa"]
+
+input[value=""] - keydown: a (97)
+input[value=""] - keypress: a (97)
+input[value="a"] - input
+input[value="a"] - keydown: a (97)
+input[value="a"] - keypress: a (97)
+input[value="aa"] - input
+`)
+ })
+
+ it('fires event multiple times and releases key', () => {
+ const {element, getEventSnapshot, clearEventCalls} = setup(``)
+ ;(element as HTMLInputElement).focus()
+ clearEventCalls()
+
+ userEvent.keyboard('{a>2/}')
+
+ expect(getEventSnapshot()).toMatchInlineSnapshot(`
+Events fired on: input[value="aa"]
+
+input[value=""] - keydown: a (97)
+input[value=""] - keypress: a (97)
+input[value="a"] - input
+input[value="a"] - keydown: a (97)
+input[value="a"] - keypress: a (97)
+input[value="aa"] - input
+input[value="aa"] - keyup: a (97)
+`)
+ })
+
+ it('fires event multiple times for multiple keys', () => {
+ const {element, getEventSnapshot, clearEventCalls} = setup(``)
+ ;(element as HTMLInputElement).focus()
+ clearEventCalls()
+
+ userEvent.keyboard('{a>2}{b>2/}{c>2}{/a}')
+
+ expect(getEventSnapshot()).toMatchInlineSnapshot(`
+Events fired on: input[value="aabbcc"]
+
+input[value=""] - keydown: a (97)
+input[value=""] - keypress: a (97)
+input[value="a"] - input
+input[value="a"] - keydown: a (97)
+input[value="a"] - keypress: a (97)
+input[value="aa"] - input
+input[value="aa"] - keydown: b (98)
+input[value="aa"] - keypress: b (98)
+input[value="aab"] - input
+input[value="aab"] - keydown: b (98)
+input[value="aab"] - keypress: b (98)
+input[value="aabb"] - input
+input[value="aabb"] - keyup: b (98)
+input[value="aabb"] - keydown: c (99)
+input[value="aabb"] - keypress: c (99)
+input[value="aabbc"] - input
+input[value="aabbc"] - keydown: c (99)
+input[value="aabbc"] - keypress: c (99)
+input[value="aabbcc"] - input
+input[value="aabbcc"] - keyup: a (97)
+`)
+ })
+})
diff --git a/src/keyboard/getNextKeyDef.ts b/src/keyboard/getNextKeyDef.ts
index a7003894..4e6a2e09 100644
--- a/src/keyboard/getNextKeyDef.ts
+++ b/src/keyboard/getNextKeyDef.ts
@@ -1,5 +1,24 @@
import {keyboardKey, keyboardOptions} from './types'
+enum bracketDict {
+ '{' = '}',
+ '[' = ']',
+}
+
+enum legacyModifiers {
+ 'alt' = 'alt',
+ 'ctrl' = 'ctrl',
+ 'meta' = 'meta',
+ 'shift' = 'shift',
+}
+
+enum legacyKeyMap {
+ ctrl = 'Control',
+ del = 'Delete',
+ esc = 'Escape',
+ space = ' ',
+}
+
/**
* Get the next key from keyMap
*
@@ -7,6 +26,8 @@ import {keyboardKey, keyboardOptions} from './types'
* Everything else will be interpreted as a typed character - e.g. `a`.
* Brackets `{` and `[` can be escaped by doubling - e.g. `foo[[bar` translates to `foo[bar`.
* Keeping the key pressed can be written as `{key>}`.
+ * When keeping the key pressed you can choose how long (how many keydown and keypress) the key is pressed `{key>3}`.
+ * You can then release the key per `{key>3/}` or keep it pressed and continue with the next key.
* Modifiers like `{shift}` imply being kept pressed. This can be turned of per `{shift/}`.
*/
export function getNextKeyDef(
@@ -17,123 +38,180 @@ export function getNextKeyDef(
consumedLength: number
releasePrevious: boolean
releaseSelf: boolean
+ repeat: number
} {
- const startBracket = ['{', '['].includes(text[0]) ? text[0] : ''
- const startModifier = startBracket && text[1] === '/' ? '/' : ''
+ const {
+ type,
+ descriptor,
+ consumedLength,
+ releasePrevious,
+ releaseSelf,
+ repeat,
+ } = readNextDescriptor(text)
- const descriptorStart = startBracket.length + startModifier.length
- const descriptor = startBracket
- ? text[descriptorStart] === startBracket
- ? startBracket
- : text.slice(descriptorStart).match(/^\w+/)?.[0]
- : text[descriptorStart]
+ const keyDef = options.keyboardMap.find(def => {
+ if (type === '[') {
+ return def.code?.toLowerCase() === descriptor.toLowerCase()
+ } else if (type === '{') {
+ const key = mapLegacyKey(descriptor)
+ return def.key?.toLowerCase() === key.toLowerCase()
+ }
+ return def.key === descriptor
+ }) ?? {
+ key: 'Unknown',
+ code: 'Unknown',
+ [type === '[' ? 'code' : 'key']: descriptor,
+ }
- if (!descriptor) {
- throw new Error(
- getErrorMessage('key descriptor', text[descriptorStart], text),
- )
+ return {
+ keyDef,
+ consumedLength,
+ releasePrevious,
+ releaseSelf,
+ repeat,
}
+}
+
+function readNextDescriptor(text: string) {
+ let pos = 0
+ const startBracket =
+ text[pos] in bracketDict ? (text[pos] as keyof typeof bracketDict) : ''
+
+ pos += startBracket.length
- const descriptorEnd = descriptorStart + descriptor.length
- const endModifier =
- startBracket &&
- descriptor !== startBracket &&
- ['/', '>'].includes(text[descriptorEnd])
- ? text[descriptorEnd]
- : ''
-
- const endBracket =
- !startBracket || descriptor === startBracket
- ? ''
- : startBracket === '{'
- ? '}'
- : ']'
-
- if (endBracket && text[descriptorEnd + endModifier.length] !== endBracket) {
+ // `foo{{bar` is an escaped char at position 3,
+ // but `foo{{{>5}bar` should be treated as `{` pressed down for 5 keydowns.
+ const startBracketRepeated = startBracket
+ ? (text.match(new RegExp(`^\\${startBracket}+`)) as RegExpMatchArray)[0]
+ .length
+ : 0
+ const isEscapedChar =
+ startBracketRepeated === 2 ||
+ (startBracket === '{' && startBracketRepeated > 3)
+
+ const type = isEscapedChar ? '' : startBracket
+
+ return {
+ type,
+ ...(type === '' ? readPrintableChar(text, pos) : readTag(text, pos, type)),
+ }
+}
+
+function readPrintableChar(text: string, pos: number) {
+ const descriptor = text[pos]
+
+ assertDescriptor(descriptor, text, pos)
+
+ pos += descriptor.length
+
+ return {
+ consumedLength: pos,
+ descriptor,
+ releasePrevious: false,
+ releaseSelf: true,
+ repeat: 1,
+ }
+}
+
+function readTag(
+ text: string,
+ pos: number,
+ startBracket: keyof typeof bracketDict,
+) {
+ const releasePreviousModifier = text[pos] === '/' ? '/' : ''
+
+ pos += releasePreviousModifier.length
+
+ const descriptor = text.slice(pos).match(/^\w+/)?.[0]
+
+ assertDescriptor(descriptor, text, pos)
+
+ pos += descriptor.length
+
+ const repeatModifier = text.slice(pos).match(/^>\d+/)?.[0] ?? ''
+
+ pos += repeatModifier.length
+
+ const releaseSelfModifier =
+ text[pos] === '/' || (!repeatModifier && text[pos] === '>') ? text[pos] : ''
+
+ pos += releaseSelfModifier.length
+
+ const expectedEndBracket = bracketDict[startBracket]
+ const endBracket = text[pos] === expectedEndBracket ? expectedEndBracket : ''
+
+ if (!endBracket) {
throw new Error(
getErrorMessage(
- 'closing bracket',
- text[descriptorEnd + endModifier.length],
+ [
+ !repeatModifier && 'repeat modifier',
+ !releaseSelfModifier && 'release modifier',
+ `"${expectedEndBracket}"`,
+ ]
+ .filter(Boolean)
+ .join(' or '),
+ text[pos],
text,
),
)
}
- const modifiers = {
- consumedLength: [
+ pos += endBracket.length
+
+ return {
+ consumedLength: pos,
+ descriptor,
+ releasePrevious: !!releasePreviousModifier,
+ repeat: repeatModifier ? Math.max(Number(repeatModifier.substr(1)), 1) : 1,
+ releaseSelf: hasReleaseSelf(
startBracket,
- startModifier,
descriptor,
- endModifier,
- endBracket,
- ]
- .map(c => c.length)
- .reduce((a, b) => a + b),
-
- releasePrevious: startModifier === '/',
- releaseSelf: hasReleaseSelf(startBracket, descriptor, endModifier),
+ releaseSelfModifier,
+ repeatModifier,
+ ),
}
+}
- if (isPrintableCharacter(startBracket, descriptor)) {
- return {
- ...modifiers,
- keyDef: options.keyboardMap.find(k => k.key === descriptor) ?? {
- key: descriptor,
- code: 'Unknown',
- },
- }
- } else if (startBracket === '{') {
- const key = mapLegacyKey(descriptor)
- return {
- ...modifiers,
- keyDef: options.keyboardMap.find(
- k => k.key?.toLowerCase() === key.toLowerCase(),
- ) ?? {key: descriptor, code: 'Unknown'},
- }
- } else {
- return {
- ...modifiers,
- keyDef: options.keyboardMap.find(
- k => k.code?.toLowerCase() === descriptor.toLowerCase(),
- ) ?? {key: 'Unknown', code: descriptor},
- }
+function assertDescriptor(
+ descriptor: string | undefined,
+ text: string,
+ pos: number,
+): asserts descriptor is string {
+ if (!descriptor) {
+ throw new Error(getErrorMessage('key descriptor', text[pos], text))
}
}
+function getEnumValue(f: Record, key: string): T | undefined {
+ return f[key]
+}
+
function hasReleaseSelf(
- startBracket: string,
+ startBracket: keyof typeof bracketDict,
descriptor: string,
- endModifier: string,
+ releaseSelfModifier: string,
+ repeatModifier: string,
) {
- if (endModifier === '/' || !startBracket) {
- return true
+ if (releaseSelfModifier) {
+ return releaseSelfModifier === '/'
}
+
+ if (repeatModifier) {
+ return false
+ }
+
if (
startBracket === '{' &&
- ['alt', 'ctrl', 'meta', 'shift'].includes(descriptor.toLowerCase())
+ getEnumValue(legacyModifiers, descriptor.toLowerCase())
) {
return false
}
- return endModifier !== '>'
-}
-function mapLegacyKey(descriptor: string) {
- return (
- {
- ctrl: 'Control',
- del: 'Delete',
- esc: 'Escape',
- space: ' ',
- }[descriptor] ?? descriptor
- )
+ return true
}
-function isPrintableCharacter(startBracket: string, descriptor: string) {
- return (
- !startBracket ||
- startBracket === descriptor ||
- (startBracket === '{' && descriptor.length === 1)
- )
+function mapLegacyKey(descriptor: string) {
+ return getEnumValue(legacyKeyMap, descriptor) ?? descriptor
}
function getErrorMessage(
diff --git a/src/keyboard/keyboardImplementation.ts b/src/keyboard/keyboardImplementation.ts
index 2b3ed8f8..1f68f930 100644
--- a/src/keyboard/keyboardImplementation.ts
+++ b/src/keyboard/keyboardImplementation.ts
@@ -18,10 +18,8 @@ export async function keyboardImplementation(
const {document} = options
const getCurrentElement = () => getActive(document)
- const {keyDef, consumedLength, releasePrevious, releaseSelf} = getNextKeyDef(
- text,
- options,
- )
+ const {keyDef, consumedLength, releasePrevious, releaseSelf, repeat} =
+ state.repeatKey ?? getNextKeyDef(text, options)
const replace = applyPlugins(
plugins.replaceBehavior,
@@ -33,7 +31,9 @@ export async function keyboardImplementation(
if (!replace) {
const pressed = state.pressed.find(p => p.keyDef === keyDef)
- if (pressed) {
+ // Release the key automatically if it was pressed before.
+ // Do not release the key on iterations on `state.repeatKey`.
+ if (pressed && !state.repeatKey) {
keyup(
keyDef,
getCurrentElement,
@@ -55,16 +55,31 @@ export async function keyboardImplementation(
keypress(keyDef, getCurrentElement, options, state)
}
- if (releaseSelf) {
+ // Release the key only on the last iteration on `state.repeatKey`.
+ if (releaseSelf && repeat <= 1) {
keyup(keyDef, getCurrentElement, options, state, unpreventedDefault)
}
}
}
- if (text.length > consumedLength) {
+ if (repeat > 1) {
+ state.repeatKey = {
+ // don't consume again on the next iteration
+ consumedLength: 0,
+ keyDef,
+ releasePrevious,
+ releaseSelf,
+ repeat: repeat - 1,
+ }
+ } else {
+ delete state.repeatKey
+ }
+
+ if (text.length > consumedLength || repeat > 1) {
if (options.delay > 0) {
await wait(options.delay)
}
+
return keyboardImplementation(text.slice(consumedLength), options, state)
}
return void undefined
diff --git a/src/keyboard/types.ts b/src/keyboard/types.ts
index 1fc949d5..70cead57 100644
--- a/src/keyboard/types.ts
+++ b/src/keyboard/types.ts
@@ -1,3 +1,5 @@
+import {getNextKeyDef} from './getNextKeyDef'
+
/**
* @internal Do not create/alter this by yourself as this type might be subject to changes.
*/
@@ -36,6 +38,11 @@ export type keyboardState = {
E.g. ^1
*/
carryChar: string
+
+ /**
+ Repeat keydown and keypress event
+ */
+ repeatKey?: ReturnType
}
export type keyboardOptions = {