Keyboard Shortcuts on a Custom Node
Task: a custom Node you've built (a selectable item, a rotatable dial) needs a keyboard shortcut beyond simple activation or dragging — deleting the item with Delete, rotating it a fixed step with [/], and so on.
KeyboardListener (from scenerystack/scenery) is the general-purpose tool for this: declare the key combination(s) as strings, attach it via addInputListener, and the Node must be focusable (tagName/focusable) so it participates in the Parallel DOM at all — the listener only fires while its target (or a descendant) has focus.
The solution
import { Node, Path, KeyboardListener } from 'scenerystack/scenery';
import { Shape } from 'scenerystack/kite';
import { NumberProperty } from 'scenerystack/axon';
class RotatableDial extends Node {
public readonly angleProperty = new NumberProperty( 0 ); // radians
public constructor() {
super( {
tagName: 'div',
focusable: true,
accessibleName: 'Dial'
} );
const dialShape = new Path( Shape.circle( 0, 0, 30 ), {
fill: 'lightGray',
stroke: 'black'
} );
const pointer = new Path( Shape.lineSegment( 0, 0, 0, -28 ), { stroke: 'crimson', lineWidth: 3 } );
this.children = [ dialShape, pointer ];
this.angleProperty.link( angle => {
pointer.rotation = angle;
} );
const ROTATE_STEP = Math.PI / 12; // 15 degrees
this.addInputListener( new KeyboardListener( {
keys: [ '[', ']' ],
fire: ( event, keysPressed ) => {
const direction = keysPressed === ']' ? 1 : -1;
this.angleProperty.value += direction * ROTATE_STEP;
},
fireOnHold: true // holding the key keeps rotating, not just one step per press
} ) );
}
}Pressing [/] while RotatableDial (or a descendant) has focus rotates angleProperty by a fixed step each time; fireOnHold: true lets holding the key repeat the rotation instead of requiring separate presses.
A delete/remove shortcut
The same pattern covers a "remove this selected item" shortcut, listening for either of two keys with one KeyboardListener:
selectedItemNode.addInputListener( new KeyboardListener( {
keys: [ 'delete', 'backspace' ],
fire: () => {
model.removeItem( item );
}
} ) );Handling a modifier variant of the same key
If the same key should do something different when a modifier is held (a larger step with Shift+]), list both combinations and branch on keysPressed:
this.addInputListener( new KeyboardListener( {
keys: [ ']', 'shift+]' ],
fire: ( event, keysPressed ) => {
const step = keysPressed === 'shift+]' ? ROTATE_STEP * 4 : ROTATE_STEP;
this.angleProperty.value += step;
}
} ) );Options used here
| Option | Effect |
|---|---|
keys | Array of key combination strings this listener responds to |
fire | ( event, keysPressed, listener ) => void, called when a combination fires; keysPressed says which one |
fireOnHold | Whether holding the combination repeats fire (default false) |
Document the shortcut, don't rely on discovery
A hotkey with no visible affordance is easy for a keyboard user to never find. Real sims register their custom shortcuts with the sim-wide keyboard-help dialog content (built from the same HotkeyData/KeyboardListener machinery) so they show up in the help dialog every screen already provides — see Keyboard Input and Hotkeys for the lower-level Hotkey/HotkeyData building blocks this integrates with.
A hotkey with no matching pointer/PDOM-visible affordance is only half the interaction
Adding [/] rotation via KeyboardListener doesn't by itself give a mouse/touch user any way to rotate the dial — make sure an equivalent pointer interaction exists too (e.g. a DragListener on the dial itself), the same "every interaction needs both an input modality and its accessible equivalent" principle covered in Alternative Input Overview.