mouseless is a React library for defining high-level keyboard interactions.
Install from npm:
npm install @ftyyy/mouseless
See this demo page for the interactive examples.
Code below are simplified. For full example code, see the example folder.
|
Track Held Keys Detect when specific key combinations (e.g. import { useKeyHoldingState } from "@ftyyy/mouseless"
function MyComponent(){
const holding = useKeyHoldingState(["Ctrl", "s"])
return holding && <CatDance />
} |
|
|
Navigate UI with the Keyboard Move between UI elements and simulates clicking with keyboard. import {
useKeyHoldingState,
useSpaceNavigatorState,
useKeyEventsHandlerRegister,
} from "@ftyyy/mouseless"
function MyComponent({onClick}){
const keys = ["Alt", "w"]
const holding = useKeyHoldingState(keys)
const [space, node] = useSpaceNavigatorState()
const [
add_handler, del_handler
] = useKeyEventsHandlerRegister()
React.useEffect(()=>{
const handler = ()=>(
(space == "my_space") && onClick(node)
)
add_handler(keys, "Enter", false, handler)
return ()=>{
del_handler(keys,"Enter", false, handler)
}
}, [space , node])
return (holding && (space == "my_space")) && (
<Penel cur_node={node} />
)
} |
|
|
Move Elements with Keyboard Simulate "drag and move" behavior with arrow keys. import { useKeyHoldingState } from "@ftyyy/mouseless"
function MyComponent(){
const keys = ["Alt", "r"]
const holding = useKeyHoldingState(keys)
const L = useKeyHoldingState([...keys, "ArrowLeft"])
const R = useKeyHoldingState([...keys, "ArrowRight"])
const U = useKeyHoldingState([...keys, "ArrowUp"])
const D = useKeyHoldingState([...keys, "ArrowDown"])
const [pos, set_pos] = React.useState({x: 0, y: 0})
React.useEffect(()=>{
const interval = setInterval(() => {
let dx = (R ? 4 : 0) - (L ? 4 : 0)
let dy = (D ? 4 : 0) - (U ? 4 : 0)
set_pos({x: pos.x + dx, y: pos.y + dy})
}, 10)
return ()=>{clearInterval(interval)}
}, [pos, L, R, U, D])
return holding && <Plane x={pos.x} y={pos.y} />
} |
Wrap all keyboard-aware components inside a KeyEventManager, which delegates keyDown and keyUp events. Use preventing_default to suppress default browser behavior for specified key combinations.
import * as React from "react"
import { KeyEventManager, KeyNames } from "@ftyyy/mouseless"
function App(){
return <KeyEventManager
preventing_default = {[KeyNames.ctrl, KeyNames.s]} // prevent ctrl+s
>{(keydown_proxy, keyup_proxy) => (
<div
tabIndex = {0}
onKeyDown = {keydown_proxy}
onKeyUp = {keyup_proxy}
>
<YourComponent />
</div>
)}</KeyEventManager>
}mouseless will provide information of the following states/events:
- holding: for a key combination that is being pressed & held, they are called holding keys.
mouselesskeeps track of the state of all holding keys. - pressing / releasing events: when a key that is being pressed or being released, it will trigger an event (just like the original
keyDownandkeyUpevents). - the combination of these two types of information.
mouseless provides an object KeyNames to define the names of keys. But you can also just use strings as key names.
import { KeyNames } from "@ftyyy/mouseless"
console.log(KeyNames.ctrl) // "Control"
console.log(KeyNames.Control) // "Control"mouseless provides several hooks for accessing keyboard states and listening to high-level keyboard events.
-
useKeyHoldingStateReturns
trueif the given key combination is currently being held.import * as React from "react" import { useKeyHoldingState , KeyNames , } from "@ftyyy/mouseless" function YourComponent(){ // whether a combination of keys are pressed const holding = useKeyHoldingState([KeyNames.ctrl, KeyNames.s]) return holding ? <p>pressed ctrl + s</p> : <></> }
-
useKeyEventsHandlerRegisterReturns
[add_handler, del_handler]functions to register/unregister high-level key event listeners.add_handlerlistens to the event that a key is pressing or releasing, while a combination of keys are being held. Unlike the originalkeyDownevent, even when the pressing key is kept held after pressing, the event will only be triggered once.add_handler: ( KeyName[], // key combination that needs to be held to trigger the event KeyName, // key that needs to be pressing / releasing to trigger the event boolean, // `true`: listen to key releasing; `false`: listen to key pressing (e: KeyEvent)=>void , // callback function when the event is triggered. ) => void
The
del_handlerhas the same arguments withadd_handler.import * as React from "react" import { useKeyEventsHandlerRegister , KeyNames , } from "@ftyyy/mouseless" function YourComponent(){ const [add_handler, del_handler] = useKeyEventsHandlerRegister() React.useEffect(()=>{ const handler_1 = ()=>{ console.log("ctrl+w+enter pressed!") } const handler_2 = ()=>{ console.log("ctrl+w+enter unpressed!") } add_handler( [KeyNames.alt, KeyNames.w], // when alt+w are held ... KeyNames.Enter, // and enter is ... false, // pressed, ... handler_1 , // trigger the handler function `handler_1`. ) add_handler( [KeyNames.alt, KeyNames.w], // when alt+w are held ... KeyNames.Enter, // and enter is ... true, // released, ... handler_2 , // trigger the handler function `handler_2`. ) return ()=>{ del_handler( [KeyNames.alt, KeyNames.w], KeyNames.Enter, false, handler_1 , ) del_handler( [KeyNames.alt, KeyNames.w], KeyNames.Enter, true, handler_2 , ) } },[]) return holding ? <p>pressed ctrl + s</p> : <></> }
When the second argument of
add_handler(as well asdel_handler) is an empty string, it listens to the reach of a certain key combination, no matter which key is the last to be pressed.For example, the following handler will be triggered either when
altis held andwis being pressed, or whenwis held andaltis being pressed.add_handler([KeyNames.alt, KeyNames.w], "", false, handler)
Similarly, the following handler will be triggered either when
altis held andwis being released, or whenwis held andaltis being released.add_handler([KeyNames.alt, KeyNames.w], "", true, handler)
You can also use
"down"or"up"as the third argument.// these two lines has exactly the same behavior add_handler([KeyNames.alt, KeyNames.w], KeyNames.Enter, false, handler) add_handler([KeyNames.alt, KeyNames.w], KeyNames.Enter, "down", handler)
The Space Navigator is a built-in plugin provided by mouseless that enables keyboard-based navigation across UI elements.
A navigator is defined as a graph (called space). Each node of a space is a string and each edge defines a directional transition between two nodes, triggered by a specific key.
Each space need to have a holding key combination. The space is activated only when this key combination is held.
Each edge requires a pressing key that initiates movement from one node to another. It will only take effect when the corresponding space is currently active (i.e. the holding keys are held).
import { SpaceDefinition } from "@ftyyy/mouseless"
const my_space: SpaceDefinition = {
name : "my_space", // the unique name of the space
nodes : ["1", "2", "3"], // the node list of the space
onStart : (last_node)=> (last_node ?? "1"), // the node to be activated when first enter the space
// the key combinations that enters the space when held
holding : [KeyNames.alt, KeyNames.w],
// transition rules between nodes.
// trnaisiton will take effect when the `holding` keys of the space are pressed and `trigger` key of the egde is being pressed.
edges : [
{
pressing: KeyNames.ArrowRight,
onMove: (cur_node)=>(
// return `"no_action"` means not triggering this edge.
({"1": "2", "2": "3", "3": "1"})[cur_node] ?? "_no_action"
)
} ,
{
pressing: KeyNames.ArrowLeft ,
onMove: (cur_node)=>(
({"2": "1", "3": "2", "1": "3"})[cur_node] ?? "_no_action"
)
}
],
}To apply a space, pass it to the KeyEventManager:
import { KeyEventManager } from "@ftyyy/mouseless"
function App(){
return <KeyEventManager
spaces = {[my_space]} // pass the space definition to `KeyEventManager` here!
preventing_default = {[
[KeyNames.alt, KeyNames.w], // surpass the default behavior
]}
>{(keydown_proxy, keyup_proxy) => (
<div
tabIndex = {0}
onKeyDown = {keydown_proxy}
onKeyUp = {keyup_proxy}
>
<YourComponent />
</div>
)}</KeyEventManager>
}useSpaceNavigatorState: Returns the names of current activated space and node.useSpaceNavigatorOnMoveRegister: Provides register/unregister functions for movement handlers. These functions will be called when a movement between nodes in the space is happening.
import * as React from "react"
import {
useSpaceNavigatorState ,
useSpaceNavigatorOnMoveRegister ,
} from "@ftyyy/mouseless"
import type {
SpaceName , // (they are both strings actually. You can just use `string`)
NodeName ,
} from "@ftyyy/mouseless"
function YourComponent(){
// get current activated space and node
const [space, node] = useSpaceNavigatorState()
// get registeration functions for moving events listeners
const [add_move_handler, del_move_handler] = useSpaceNavigatoronMoveRegister()
React.useEffect(()=>{
// define a moving event handler
const handler = (
start_space ?: SpaceName,
start_node ?: NodeName,
end_space ?: SpaceName,
end_node ?: NodeName ,
)=>{
if(start_space == end_space && start_space == "my_space"){
set_words(`moved from ${start_node} to ${end_node}`)
}
}
// register the handler
add_move_handler(handler)
return ()=>{
// unregister the handler when unmount
del_move_handler(handler)
}
},[])
// if a node is current activated, give it a solid border.
const make_styles = (node_name: string)=>{
if(node == node_name){
return {
style: { border: "1px solid #000" }
}
}
return {}
}
return (space == "my_space") ? (<div>
<p {...make_styles("1")}>element 1</p>
<p {...make_styles("2")}>element 2</p>
<p {...make_styles("3")}>element 3</p>
<div>) : <></>
}useSpaceNavigatorState accepts two optional parameters: target_space: SpaceName and target_node: NodeName. With one of two parameters specified, useSpaceNavigatorState will only return results when the specified parameter is met, and otherwise return [undefined, undefined]. This allows a finer control over the rerender behavior of the React component.
import * as React from "react"
import {
useSpaceNavigatorState ,
} from "@ftyyy/mouseless"
function YourComponent(){
// only rerender when `space=="my_space"`
const [space, node] = useSpaceNavigatorState("my_space")
return (space == "my_space") ? <div>my space!</div> : <></>
}mouseless also provides a lower-level hook useSpaceNavigatorRawState, allowing users to specify a selector. The selector can access to the internal state and return whatever inforamtion the user requires. The component would not rerender as long as the return value of useSpaceNavigatorRawState is unchanged. Use state.space and state.node to access current space and node.
import * as React from "react"
import {
useSpaceNavigatorState ,
} from "@ftyyy/mouseless"
// a space shared by two components
const my_space: SpaceDefinition = {
name : "my_space",
// 2 components share the same space.
// node format: `${component_idx},{node_idx}`.
nodes : [
"0,0", "0,1", "0,2",
"1,0", "1,1", "1,2"
],
onStart : (last_node)=> (last_node ?? "0,0"),
holding : [KeyNames.alt, KeyNames.w],
edges : [
{// switch component
pressing: KeyNames.ArrowUp,
onMove: (cur_node)=>{
const [component, idx] = cur_node.split(",")
return `${parseInt(component) ^ 1},${idx}`
}
} ,
{// switch component
pressing: KeyNames.ArrowDown,
onMove: (cur_node)=>{
const [component, idx] = cur_node.split(",")
return `${parseInt(component) ^ 1},${idx}`
}
} ,
{// switch idx
pressing: KeyNames.ArrowLeft,
onMove: (cur_node)=>{
const [component, idx] = cur_node.split(",")
return `${component},${(parseInt(idx)+2)%3}`
}
} ,
{// switch idx
pressing: KeyNames.ArrowLeft,
onMove: (cur_node)=>{
const [component, idx] = cur_node.split(",")
return `${component},${(parseInt(idx)+1)%3}`
}
} ,
],
}
function YourComponent_1(){
const node = useSpaceNavigatorRawState(React.useCallback((state)=>{
const {space, node} = state
if(space != my_space.name){
// when navigating in other spaces, the component would not rerender.
return undefined
}
const [component, idx] = node.split(",")
if(components != "1"){
// when switching in this space but the other component, this component would also not rerender.
return undefined
}
return idx
}, []))
return <div>current node is {node}</div>
}
function YourComponent_2(){
const node = useSpaceNavigatorRawState(React.useCallback((state)=>{
const {space, node} = state
if(space != my_space.name){
// when navigating in other spaces, the component would not rerender.
return undefined
}
const [component, idx] = node.split(",")
if(components != "2"){
// when switching in this space but the other component, this component would also not rerender.
return undefined
}
return idx
}, []))
return <div>current node is {node}</div>
}

