feat: useKeyboard hooks (#541)

* feat(keyboard): create keyboard hooks

* feat(usekeyboard): redesign event handler to match keyboard events from browser

\

* test(usekeyboard): add testcase

* docs(usekeyboard): create new hooks document
This commit is contained in:
witt
2021-05-24 00:17:58 +08:00
committed by GitHub
parent 4032eafe0b
commit fc06a02335
12 changed files with 570 additions and 8 deletions

View File

@@ -14,6 +14,7 @@ export { default as useClickAway } from './use-click-away'
export { default as useClipboard } from './use-clipboard'
export { default as useCurrentState } from './use-current-state'
export { default as useMediaQuery } from './use-media-query'
export { default as useKeyboard, KeyMod, KeyCode } from './use-keyboard'
export { default as Avatar } from './avatar'
export { default as Text } from './text'
export { default as Note } from './note'

View File

@@ -0,0 +1,177 @@
import React from 'react'
import { mount } from 'enzyme'
import { useKeyboard, KeyMod, KeyCode } from 'components'
import { renderHook, act } from '@testing-library/react-hooks'
import { KeyboardResult } from '../use-keyboard'
describe('UseKeyboard', () => {
it('should work correctly', () => {
let code = null
const handler = jest.fn().mockImplementation(e => {
code = e.keyCode
})
renderHook(() => useKeyboard(handler, KeyCode.KEY_H))
document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: KeyCode.KEY_H }))
expect(handler).toBeCalledTimes(1)
expect(code).toEqual(KeyCode.KEY_H)
})
it('should not trigger handler', () => {
const handler = jest.fn().mockImplementation(() => {})
renderHook(() => useKeyboard(handler, [KeyCode.KEY_0]))
const event = new KeyboardEvent('keydown', { keyCode: KeyCode.KEY_1 })
document.dispatchEvent(event)
expect(handler).not.toBeCalled()
})
it('should trigger with command key', () => {
const handler = jest.fn().mockImplementation(() => {})
renderHook(() => useKeyboard(handler, [KeyCode.KEY_A, KeyMod.Shift]))
const event = new KeyboardEvent('keydown', { keyCode: KeyCode.KEY_A })
document.dispatchEvent(event)
expect(handler).not.toBeCalled()
const event2 = new KeyboardEvent('keydown', {
keyCode: KeyCode.KEY_A,
shiftKey: true,
})
document.dispatchEvent(event2)
expect(handler).toBeCalledTimes(1)
})
it('should ignore command when code does not exist', () => {
const handler = jest.fn().mockImplementation(() => {})
renderHook(() => useKeyboard(handler, [KeyCode.KEY_A, 12345]))
const event = new KeyboardEvent('keydown', { keyCode: KeyCode.KEY_A })
document.dispatchEvent(event)
expect(handler).toBeCalled()
})
it('should work with each command', () => {
const handler = jest.fn().mockImplementation(() => {})
renderHook(() =>
useKeyboard(handler, [KeyCode.KEY_A, KeyMod.Alt, KeyMod.CtrlCmd, KeyMod.WinCtrl]),
)
document.dispatchEvent(
new KeyboardEvent('keydown', {
keyCode: KeyCode.KEY_A,
}),
)
expect(handler).not.toBeCalled()
document.dispatchEvent(
new KeyboardEvent('keydown', {
keyCode: KeyCode.KEY_A,
altKey: true,
}),
)
expect(handler).not.toBeCalled()
document.dispatchEvent(
new KeyboardEvent('keydown', {
keyCode: KeyCode.KEY_A,
altKey: true,
ctrlKey: true,
}),
)
expect(handler).not.toBeCalled()
document.dispatchEvent(
new KeyboardEvent('keydown', {
keyCode: KeyCode.KEY_A,
altKey: true,
ctrlKey: true,
metaKey: true,
}),
)
expect(handler).toBeCalledTimes(1)
})
it('should ignore global events', () => {
const handler = jest.fn().mockImplementation(() => {})
renderHook(() => useKeyboard(handler, [KeyCode.KEY_A], { disableGlobalEvent: true }))
const event = new KeyboardEvent('keydown', { keyCode: KeyCode.KEY_A })
document.dispatchEvent(event)
expect(handler).not.toBeCalled()
})
it('should respond to different event types', () => {
const handler = jest.fn().mockImplementation(() => {})
renderHook(() => useKeyboard(handler, [KeyCode.KEY_A], { event: 'keyup' }))
document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: KeyCode.KEY_A }))
expect(handler).not.toBeCalled()
document.dispatchEvent(new KeyboardEvent('keypress', { keyCode: KeyCode.KEY_A }))
expect(handler).not.toBeCalled()
document.dispatchEvent(new KeyboardEvent('keyup', { keyCode: KeyCode.KEY_A }))
expect(handler).toBeCalled()
})
it('should pass the keyboard events', () => {
const handler = jest.fn().mockImplementation(() => {})
const nativeHandler = jest.fn().mockImplementation(() => {})
const { result } = renderHook<void, KeyboardResult>(() =>
useKeyboard(handler, KeyCode.Escape),
)
const wrapper = mount(
<div onKeyDown={nativeHandler}>
<span id="inner" {...result.current.bindings} />
</div>,
)
const inner = wrapper.find('#inner').at(0)
act(() => {
inner.simulate('keyup', {
keyCode: KeyCode.Escape,
})
})
expect(handler).not.toBeCalled()
expect(nativeHandler).not.toBeCalled()
act(() => {
inner.simulate('keydown', {
keyCode: KeyCode.Escape,
})
})
expect(handler).toBeCalled()
expect(nativeHandler).toBeCalled()
})
it('should prevent default events', () => {
const handler = jest.fn().mockImplementation(() => {})
const nativeHandler = jest.fn().mockImplementation(() => {})
const { result } = renderHook<void, KeyboardResult>(() =>
useKeyboard(handler, KeyCode.Escape, {
disableGlobalEvent: true,
stopPropagation: true,
}),
)
const wrapper = mount(
<div onKeyDown={nativeHandler}>
<span id="inner" {...result.current.bindings} />
</div>,
)
const inner = wrapper.find('#inner').at(0)
act(() => {
inner.simulate('keydown', {
keyCode: KeyCode.Escape,
})
})
expect(handler).toBeCalled()
expect(nativeHandler).not.toBeCalled()
})
it('should trigger capture event', () => {
const handler = jest.fn().mockImplementation(() => {})
const { result } = renderHook<void, KeyboardResult>(() =>
useKeyboard(handler, KeyCode.Escape, { capture: true, disableGlobalEvent: true }),
)
const wrapper = mount(
<div onKeyDownCapture={result.current.bindings.onKeyDownCapture}>
<span id="inner" />
</div>,
)
const inner = wrapper.find('#inner').at(0)
act(() => {
inner.simulate('keydown', {
keyCode: KeyCode.Escape,
})
})
expect(handler).toBeCalled()
})
})

View File

@@ -0,0 +1,94 @@
/**
* KeyBinding Codes
* The content of this file is based on the design of the open source project "microsoft/vscode",
* Copyright (c) Microsoft Corporation. All rights reserved.
*
* We inherit the KeyMod values from "microsoft/vscode",
* but use the Browser's KeyboardEvent event implementation, and all values are used only as identification.
*/
export enum KeyCode {
Unknown = 0,
Backspace = 8,
Tab = 9,
Enter = 13,
Shift = 16,
Ctrl = 17,
Alt = 18,
PauseBreak = 19,
CapsLock = 20,
Escape = 27,
Space = 32,
PageUp = 33,
PageDown = 34,
End = 35,
Home = 36,
LeftArrow = 37,
UpArrow = 38,
RightArrow = 39,
DownArrow = 40,
Insert = 45,
Delete = 46,
KEY_0 = 48,
KEY_1 = 49,
KEY_2 = 50,
KEY_3 = 51,
KEY_4 = 52,
KEY_5 = 53,
KEY_6 = 54,
KEY_7 = 55,
KEY_8 = 56,
KEY_9 = 57,
KEY_A = 65,
KEY_B = 66,
KEY_C = 67,
KEY_D = 68,
KEY_E = 69,
KEY_F = 70,
KEY_G = 71,
KEY_H = 72,
KEY_I = 73,
KEY_J = 74,
KEY_K = 75,
KEY_L = 76,
KEY_M = 77,
KEY_N = 78,
KEY_O = 79,
KEY_P = 80,
KEY_Q = 81,
KEY_R = 82,
KEY_S = 83,
KEY_T = 84,
KEY_U = 85,
KEY_V = 86,
KEY_W = 87,
KEY_X = 88,
KEY_Y = 89,
KEY_Z = 90,
Meta = 91,
F1 = 112,
F2 = 113,
F3 = 114,
F4 = 115,
F5 = 116,
F6 = 117,
F7 = 118,
F8 = 119,
F9 = 120,
F10 = 121,
F11 = 122,
F12 = 123,
NumLock = 144,
ScrollLock = 145,
Equal = 187,
Minus = 189,
Backquote = 192,
Backslash = 220,
}
export enum KeyMod {
CtrlCmd = (1 << 11) >>> 0,
Shift = (1 << 10) >>> 0,
Alt = (1 << 9) >>> 0,
WinCtrl = (1 << 8) >>> 0,
}

View File

@@ -0,0 +1,27 @@
import { isMac } from '../utils/collections'
import { KeyMod } from './codes'
/* istanbul ignore next */
export const getCtrlKeysByPlatform = (): Record<string, 'metaKey' | 'ctrlKey'> => {
return {
CtrlCmd: isMac() ? 'metaKey' : 'ctrlKey',
WinCtrl: isMac() ? 'ctrlKey' : 'metaKey',
}
}
export const getActiveModMap = (
bindings: number[],
): Record<keyof typeof KeyMod, boolean> => {
const modBindings = bindings.filter((item: number) => !!KeyMod[item])
const activeModMap: Record<keyof typeof KeyMod, boolean> = {
CtrlCmd: false,
Shift: false,
Alt: false,
WinCtrl: false,
}
modBindings.forEach(code => {
const modKey = KeyMod[code] as keyof typeof KeyMod
activeModMap[modKey] = true
})
return activeModMap
}

View File

@@ -0,0 +1,5 @@
import useKeyboard from './use-keyboard'
import { KeyMod, KeyCode } from './codes'
export default useKeyboard
export { KeyMod, KeyCode }

View File

@@ -0,0 +1,90 @@
import { KeyMod } from './codes'
import React, { useEffect } from 'react'
import { getActiveModMap, getCtrlKeysByPlatform } from './helper'
export type KeyboardOptions = {
disableGlobalEvent?: boolean
stopPropagation?: boolean
preventDefault?: boolean
capture?: boolean
event?: 'keydown' | 'keypress' | 'keyup'
}
export type KeyboardResult = {
bindings: {
onKeyDown: React.KeyboardEventHandler
onKeyDownCapture: React.KeyboardEventHandler
onKeyPress: React.KeyboardEventHandler
onKeyPressCapture: React.KeyboardEventHandler
onKeyUp: React.KeyboardEventHandler
onKeyUpCapture: React.KeyboardEventHandler
}
}
export type UseKeyboardHandler = (event: React.KeyboardEvent | KeyboardEvent) => void
export type UseKeyboard = (
handler: UseKeyboardHandler,
keyBindings: Array<number> | number,
options?: KeyboardOptions,
) => KeyboardResult
const useKeyboard: UseKeyboard = (handler, keyBindings, options = {}) => {
const bindings = Array.isArray(keyBindings) ? (keyBindings as number[]) : [keyBindings]
const {
disableGlobalEvent = false,
capture = false,
stopPropagation = false,
preventDefault = true,
event = 'keydown',
} = options
const activeModMap = getActiveModMap(bindings)
const keyCode = bindings.filter((item: number) => !KeyMod[item])[0]
const { CtrlCmd, WinCtrl } = getCtrlKeysByPlatform()
const eventHandler = (event: React.KeyboardEvent | KeyboardEvent) => {
if (activeModMap.Shift && !event.shiftKey) return
if (activeModMap.Alt && !event.altKey) return
if (activeModMap.CtrlCmd && !event[CtrlCmd]) return
if (activeModMap.WinCtrl && !event[WinCtrl]) return
if (keyCode && event.keyCode !== keyCode) return
if (stopPropagation) {
event.stopPropagation()
}
if (preventDefault) {
event.preventDefault()
}
handler && handler(event)
}
useEffect(() => {
if (!disableGlobalEvent) {
document.addEventListener(event, eventHandler)
}
return () => {
document.removeEventListener(event, eventHandler)
}
}, [disableGlobalEvent])
const elementBindingHandler = (
elementEventType: 'keydown' | 'keypress' | 'keyup',
isCapture: boolean = false,
) => {
if (elementEventType !== event) return () => {}
if (isCapture !== capture) return () => {}
return (e: React.KeyboardEvent) => eventHandler(e)
}
return {
bindings: {
onKeyDown: elementBindingHandler('keydown'),
onKeyDownCapture: elementBindingHandler('keydown', true),
onKeyPress: elementBindingHandler('keypress'),
onKeyPressCapture: elementBindingHandler('keypress', true),
onKeyUp: elementBindingHandler('keyup'),
onKeyUpCapture: elementBindingHandler('keyup', true),
},
}
}
export default useKeyboard

View File

@@ -138,3 +138,14 @@ export const isChildElement = (
}
return false
}
export const isBrowser = (): boolean => {
return Boolean(
typeof window !== 'undefined' && window.document && window.document.createElement,
)
}
export const isMac = (): boolean => {
if (!isBrowser()) return false
return navigator.platform.toUpperCase().indexOf('MAC') >= 0
}

View File

@@ -1,10 +1,5 @@
import { useEffect, useState } from 'react'
const isBrowser = (): boolean => {
return Boolean(
typeof window !== 'undefined' && window.document && window.document.createElement,
)
}
import { isBrowser } from './collections'
export type SSRState = {
isBrowser: boolean

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,81 @@
import { Layout, Playground, Attributes } from 'lib/components'
import { useKeyboard, KeyCode, KeyMod, Keyboard, Input, Link } from 'components'
export const meta = {
title: 'use-keyboard',
group: 'Utils',
}
## Use Keyboard
React Hooks for listen to multiple keyboard events.
This is custom React Hooks, you need to follow the <Link target="_blank" color href="https://reactjs.org/docs/hooks-rules.html">Basic Rules</Link> when you use it.
<Playground
desc="Global keyboard events."
scope={{ useKeyboard, KeyCode, KeyMod, Keyboard }}
code={`
() => {
useKeyboard(
() => alert('save success!'),
[KeyCode.KEY_S, KeyMod.CtrlCmd]
)
return <div>Press <Keyboard command>S</Keyboard> to save.</div>
}
`}
/>
<Playground
title="Element Event"
desc="keyboard events listening on elements."
scope={{ useKeyboard, KeyCode, KeyMod, Keyboard, Input }}
code={`
() => {
const { bindings } = useKeyboard(
() => alert('A is not allowed'),
[KeyCode.KEY_A],
{ disableGlobalEvent: true },
)
return (
<div>
<p>Keyboard events are triggered only when the element is activated.</p>
<Input {...bindings} placeholder="Press A" />
</div>
)
}
`}
/>
<Attributes edit="/pages/en-us/components/use-keyboard.mdx">
<Attributes.Title>useKeyboard</Attributes.Title>
```ts
type KeyboardOptions = {
disableGlobalEvent: boolean,
stopPropagation: boolean
preventDefault: boolean
capture: boolean
event: 'keydown' | 'keypress' | 'keyup'
}
const useKeyboard = (
handler: (event: React.KeyboardEvent) => void,
keyBindings: Array<number> | number,
options?: KeyboardOptions,
) => void
```
<Attributes.Title>KeyboardOptions</Attributes.Title>
| Option | Description | Type | Accepted values | Default |
| ---------------------- | ----------------------------------- | --------- | -------------------------------- | --------- |
| **disableGlobalEvent** | disable global events from document | `boolean` | - | `false` |
| **stopPropagation** | stop event Propagation | `boolean` | - | `false` |
| **preventDefault** | block the default behavior of event | `boolean` | - | `true` |
| **capture** | set event type to "capture" | `boolean` | - | `false` |
| **event** | keyboard event type | `string` | `'keydown', 'keypress', 'keyup'` | `keydown` |
</Attributes>
export default ({ children }) => <Layout meta={meta}>{children}</Layout>

View File

@@ -0,0 +1,81 @@
import { Layout, Playground, Attributes } from 'lib/components'
import { useKeyboard, KeyCode, KeyMod, Keyboard, Input, Link } from 'components'
export const meta = {
title: '键盘事件 useKeyboard',
group: '工具包',
}
## Use Keyboard / 键盘事件
用户监听多个键盘事件的钩子。
这是一个自定义的 React Hooks你需要在使用时遵循 <Link target="_blank" color href="https://reactjs.org/docs/hooks-rules.html">钩子的基础规则</Link>。
<Playground
desc="全局的键盘事件。"
scope={{ useKeyboard, KeyCode, KeyMod, Keyboard }}
code={`
() => {
useKeyboard(
() => alert('保存成功!'),
[KeyCode.KEY_S, KeyMod.CtrlCmd]
)
return <div>按下 <Keyboard command>S</Keyboard> 以保存。</div>
}
`}
/>
<Playground
title="元素事件"
desc="只在指定元素上监听元素事件。"
scope={{ useKeyboard, KeyCode, KeyMod, Keyboard, Input }}
code={`
() => {
const { bindings } = useKeyboard(
() => alert('A 是不被允许的'),
[KeyCode.KEY_A],
{ disableGlobalEvent: true },
)
return (
<div>
<p>键盘事件只在输入框被触发后才会响应。</p>
<Input {...bindings} placeholder="输入 A" />
</div>
)
}
`}
/>
<Attributes edit="/pages/en-us/components/use-keyboard.mdx">
<Attributes.Title>useKeyboard</Attributes.Title>
```ts
type KeyboardOptions = {
disableGlobalEvent: boolean,
stopPropagation: boolean
preventDefault: boolean
capture: boolean
event: 'keydown' | 'keypress' | 'keyup'
}
const useKeyboard = (
handler: (event: React.KeyboardEvent) => void,
keyBindings: Array<number> | number,
options?: KeyboardOptions,
) => void
```
<Attributes.Title>KeyboardOptions</Attributes.Title>
| 参数 | 描述 | 类型 | 推荐值 | 默认 |
| ---------------------- | -------------------------------- | --------- | -------------------------------- | --------- |
| **disableGlobalEvent** | 禁止监听来自 Document 的全局事件 | `boolean` | - | `false` |
| **stopPropagation** | 停止事件传播 | `boolean` | - | `false` |
| **preventDefault** | 阻止事件的默认行为 | `boolean` | - | `true` |
| **capture** | 设置事件类型为捕获 | `boolean` | - | `false` |
| **event** | 键盘事件的类型 | `string` | `'keydown', 'keypress', 'keyup'` | `keydown` |
</Attributes>
export default ({ children }) => <Layout meta={meta}>{children}</Layout>