-
Notifications
You must be signed in to change notification settings - Fork 12
/
AccessibleNumberSpinner.ts
219 lines (174 loc) · 9.38 KB
/
AccessibleNumberSpinner.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
// Copyright 2018-2022, University of Colorado Boulder
/**
* A trait for subtypes of Node, used to make the Node behave like a 'number' input with assistive technology.
* An accessible number spinner behaves like:
*
* - Arrow keys increment/decrement the value by a specified step size.
* - Page Up and Page Down increments/decrements value by an alternative step size, usually larger than default.
* - Home key sets value to its minimum.
* - End key sets value to its maximum.
*
* This number spinner is different than typical 'number' inputs because it does not support number key control. It
* was determined that an input of type range is the best match for a PhET Number Spinner, with a custom role
* description with aria-roledescription. See https://github.com/phetsims/sun/issues/497 for history on this
* decision.
*
* This trait mixes in a "parent" mixin to handle general "value" formatting and aria-valuetext updating, see
* AccessibleValueHandler.
*
* @author Jesse Greenberg (PhET Interactive Simulations)
* @author Michael Barlow (PhET Interactive Simulations)
*/
import CallbackTimer from '../../../axon/js/CallbackTimer.js';
import Emitter from '../../../axon/js/Emitter.js';
import validate from '../../../axon/js/validate.js';
import assertHasProperties from '../../../phet-core/js/assertHasProperties.js';
import Constructor from '../../../phet-core/js/Constructor.js';
import inheritance from '../../../phet-core/js/inheritance.js';
import optionize from '../../../phet-core/js/optionize.js';
import Orientation from '../../../phet-core/js/Orientation.js';
import { IInputListener, KeyboardUtils, Node, SceneryEvent, SceneryListenerFunction } from '../../../scenery/js/imports.js';
import sun from '../sun.js';
import sunStrings from '../sunStrings.js';
import AccessibleValueHandler, { AccessibleValueHandlerOptions } from './AccessibleValueHandler.js';
const numberSpinnerRoleDescriptionString = sunStrings.a11y.numberSpinnerRoleDescription;
type AccessibleNumberSpinnerSelfOptions = {
timerDelay?: number,
timerInterval?: number
}
type AccessibleNumberSpinnerOptions = AccessibleNumberSpinnerSelfOptions & AccessibleValueHandlerOptions;
/**
* @param Type
* @param optionsArgPosition - zero-indexed number that the options argument is provided at
*/
const AccessibleNumberSpinner = <SuperType extends Constructor>( Type: SuperType, optionsArgPosition: number ) => {
assert && assert( _.includes( inheritance( Type ), Node ), 'Only Node subtypes should compose Voicing' );
// Unfortunately, nothing can be private or protected in this class, see https://github.com/phetsims/scenery/issues/1340#issuecomment-1020692592
return class extends AccessibleValueHandler( Type, optionsArgPosition ) {
// Manages timing must be disposed
_callbackTimer: CallbackTimer;
// @protected - emits events when increment and decrement actions occur, but only for changes
// of keyboardStep (not pageKeyboardStep or shiftKeyboardStep)
incrementDownEmitter: Emitter<[ boolean ]>; // @protected
decrementDownEmitter: Emitter<[ boolean ]>; // @protected
_disposeAccessibleNumberSpinner: () => void;
constructor( ...args: any[] ) {
const providedOptions = args[ optionsArgPosition ] as AccessibleValueHandlerOptions;
assert && providedOptions && assert( Object.getPrototypeOf( providedOptions ) === Object.prototype,
'Extra prototype on AccessibleSlider options object is a code smell (or probably a bug)' );
const options = optionize<AccessibleNumberSpinnerOptions, AccessibleNumberSpinnerSelfOptions, AccessibleValueHandlerOptions>( {
timerDelay: 400, // start to fire continuously after pressing for this long (milliseconds)
timerInterval: 100, // fire continuously at this frequency (milliseconds),
ariaOrientation: Orientation.VERTICAL // by default, number spinners should be oriented vertically
}, providedOptions );
args[ optionsArgPosition ] = options;
super( ...args );
const thisNode = this as unknown as Node;
// members of the Node API that are used by this trait
assertHasProperties( this, [ 'addInputListener' ] );
this._callbackTimer = new CallbackTimer( {
delay: options.timerDelay,
interval: options.timerInterval
} );
this.incrementDownEmitter = new Emitter( { parameters: [ { valueType: 'boolean' } ] } );
this.decrementDownEmitter = new Emitter( { parameters: [ { valueType: 'boolean' } ] } );
thisNode.setPDOMAttribute( 'aria-roledescription', numberSpinnerRoleDescriptionString );
// a callback that is added and removed from the timer depending on keystate
let downCallback: SceneryListenerFunction | null = null;
let runningTimerCallbackEvent: Event | null = null; // {Event|null}
// handle all accessible event input
const accessibleInputListener: IInputListener = {
keydown: ( event: SceneryEvent ) => {
if ( ( this as unknown as Node ).enabledProperty.get() ) {
// check for relevant keys here
if ( KeyboardUtils.isRangeKey( event.domEvent ) ) {
// TODO: How to specify subtypes of DOMEvents, https://github.com/phetsims/scenery/issues/1340
const domEvent = event.domEvent! as Event & { metaKey: boolean };
// If the meta key is down we will not even call the keydown listener of the supertype, so we need
// to be sure that default behavior is prevented so we don't receive `input` and `change` events.
// See AccessibleValueHandler.handleInput for information on these events and why we don't want
// to change in response to them.
domEvent.preventDefault();
// When the meta key is down Mac will not send keyup events so do not change values or add timer
// listeners because they will never be removed since we fail to get a keyup event. See
if ( !domEvent.metaKey ) {
if ( !this._callbackTimer.isRunning() ) {
this._accessibleNumberSpinnerHandleKeyDown( event );
downCallback = this._accessibleNumberSpinnerHandleKeyDown.bind( this, event );
runningTimerCallbackEvent = domEvent;
this._callbackTimer.addCallback( downCallback );
this._callbackTimer.start();
}
}
}
}
},
keyup: ( event: SceneryEvent ) => {
const key = KeyboardUtils.getEventCode( event.domEvent );
if ( KeyboardUtils.isRangeKey( event.domEvent ) ) {
if ( runningTimerCallbackEvent && key === KeyboardUtils.getEventCode( runningTimerCallbackEvent ) ) {
this._emitKeyState( event.domEvent!, false );
this._callbackTimer.stop( false );
this._callbackTimer.removeCallback( downCallback );
downCallback = null;
runningTimerCallbackEvent = null;
}
this.handleKeyUp( event );
}
},
blur: ( event: SceneryEvent ) => {
// if a key is currently down when focus leaves the spinner, stop callbacks and emit that the
// key is up
if ( downCallback ) {
assert && assert( runningTimerCallbackEvent !== null, 'key should be down if running downCallback' );
this._emitKeyState( runningTimerCallbackEvent!, false );
this._callbackTimer.stop( false );
this._callbackTimer.removeCallback( downCallback );
}
this.handleBlur( event );
},
input: this.handleInput.bind( this ),
change: this.handleChange.bind( this )
};
thisNode.addInputListener( accessibleInputListener );
this._disposeAccessibleNumberSpinner = () => {
this._callbackTimer.dispose();
// emitters owned by this instance, can be disposed here
this.incrementDownEmitter.dispose();
this.decrementDownEmitter.dispose();
thisNode.removeInputListener( accessibleInputListener );
};
}
/**
* Handle the keydown event and emit events related to the user interaction. Ideally, this would
* override AccessibleValueHandler.handleKeyDown, but overriding is not supported with PhET Trait pattern.
*/
_accessibleNumberSpinnerHandleKeyDown( event: SceneryEvent ) {
assert && assert( event.domEvent, 'must have a domEvent' );
this.handleKeyDown( event );
this._emitKeyState( event.domEvent!, true );
}
/**
* Emit events related to the keystate of the spinner. Typically used to style the spinner during keyboard
* interaction.
*
* @param domEvent - the code of the key changing state
* @param isDown - whether or not event was triggered from down or up keys
*/
_emitKeyState( domEvent: Event, isDown: boolean ) {
validate( domEvent, { valueType: Event } );
if ( KeyboardUtils.isAnyKeyEvent( domEvent, [ KeyboardUtils.KEY_UP_ARROW, KeyboardUtils.KEY_RIGHT_ARROW ] ) ) {
this.incrementDownEmitter.emit( isDown );
}
else if ( KeyboardUtils.isAnyKeyEvent( domEvent, [ KeyboardUtils.KEY_DOWN_ARROW, KeyboardUtils.KEY_LEFT_ARROW ] ) ) {
this.decrementDownEmitter.emit( isDown );
}
}
dispose() {
this._disposeAccessibleNumberSpinner();
super.dispose();
}
};
};
sun.register( 'AccessibleNumberSpinner', AccessibleNumberSpinner );
export default AccessibleNumberSpinner;