Skip to content

Commit faa9348

Browse files
authored
Update ColorField to behave like NumberField (#1654)
1 parent 8e571ae commit faa9348

File tree

14 files changed

+429
-261
lines changed

14 files changed

+429
-261
lines changed

packages/@react-aria/color/src/useColorField.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ import {ColorFieldState} from '@react-stately/color';
1515
import {
1616
HTMLAttributes,
1717
LabelHTMLAttributes,
18-
RefObject
18+
RefObject,
19+
useCallback
1920
} from 'react';
2021
import {mergeProps, useId} from '@react-aria/utils';
22+
import {useFormattedTextField} from '@react-aria/textfield';
23+
import {useScrollWheel} from '@react-aria/interactions';
2124
import {useSpinButton} from '@react-aria/spinbutton';
22-
import {useTextField} from '@react-aria/textfield';
2325

2426
interface ColorFieldAria {
2527
/** Props for the label element. */
@@ -46,7 +48,6 @@ export function useColorField(
4648
let {
4749
colorValue,
4850
inputValue,
49-
setInputValue,
5051
commit,
5152
increment,
5253
decrement,
@@ -71,25 +72,29 @@ export function useColorField(
7172
}
7273
);
7374

74-
let onWheel = (e) => {
75-
if (isDisabled || isReadOnly) {
76-
return;
77-
}
78-
if (e.deltaY < 0) {
75+
let onWheel = useCallback((e) => {
76+
if (e.deltaY > 0) {
7977
increment();
80-
} else {
78+
} else if (e.deltaY < 0) {
8179
decrement();
8280
}
81+
}, [isReadOnly, isDisabled, decrement, increment]);
82+
// If the input isn't supposed to receive input, disable scrolling.
83+
let scrollingDisabled = isDisabled || isReadOnly;
84+
useScrollWheel({onScroll: onWheel, isDisabled: scrollingDisabled}, ref);
85+
86+
let onChange = value => {
87+
state.setInputValue(value);
8388
};
8489

85-
let {labelProps, inputProps} = useTextField(
90+
let {labelProps, inputProps} = useFormattedTextField(
8691
mergeProps(props, {
8792
id: inputId,
8893
value: inputValue,
8994
type: 'text',
9095
autoComplete: 'off',
91-
onChange: setInputValue
92-
}), ref);
96+
onChange
97+
}), state, ref);
9398

9499
return {
95100
labelProps,
@@ -100,8 +105,7 @@ export function useColorField(
100105
'aria-valuenow': null,
101106
'aria-valuetext': null,
102107
autoCorrect: 'off',
103-
onBlur: commit,
104-
onWheel
108+
onBlur: commit
105109
})
106110
};
107111
}

packages/@react-aria/color/test/useColorField.test.js

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ describe('useColorField', function () {
2020

2121
beforeEach(() => {
2222
ref = React.createRef();
23+
ref.current = {};
24+
ref.current.addEventListener = () => {};
25+
ref.current.removeEventListener = () => {};
2326
});
2427

2528
let renderColorFieldHook = (props, state = {}) => {
@@ -67,11 +70,6 @@ describe('useColorField', function () {
6770
expect(inputProps.id).toBeTruthy();
6871
expect(inputProps['aria-labelledby']).toBe(labelProps.id);
6972
expect(inputProps['aria-label']).toBeUndefined(); // because label prop is provided instead of aria-label
70-
expect(typeof inputProps.onChange).toBe('function');
71-
expect(typeof inputProps.onBlur).toBe('function');
72-
expect(typeof inputProps.onFocus).toBe('function');
73-
expect(typeof inputProps.onKeyDown).toBe('function');
74-
expect(typeof inputProps.onWheel).toBe('function');
7573
});
7674

7775
it('should return prop for invalid', function () {

packages/@react-aria/interactions/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ export * from './useInteractOutside';
2020
export * from './useKeyboard';
2121
export * from './useMove';
2222
export * from './usePress';
23+
export * from './useScrollWheel';
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright 2021 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {RefObject, useCallback, useEffect} from 'react';
14+
import {ScrollEvents} from '@react-types/shared';
15+
16+
export interface ScrollWheelProps extends ScrollEvents {
17+
/** Whether the scroll listener should be disabled. */
18+
isDisabled?: boolean
19+
}
20+
21+
// scroll wheel needs to be added not passively so it's cancelable, small helper hook to remember that
22+
export function useScrollWheel(props: ScrollWheelProps, ref: RefObject<HTMLElement>): void {
23+
let {onScroll, isDisabled} = props;
24+
let onScrollHandler = useCallback((e) => {
25+
// If the ctrlKey is pressed, this is a zoom event, do nothing.
26+
if (isDisabled || e.ctrlKey) {
27+
return;
28+
}
29+
30+
// stop scrolling the page
31+
e.preventDefault();
32+
e.stopPropagation();
33+
34+
if (onScroll) {
35+
onScroll({deltaX: e.deltaX, deltaY: e.deltaY});
36+
}
37+
}, [onScroll, isDisabled]);
38+
39+
useEffect(() => {
40+
let elem = ref.current;
41+
elem.addEventListener('wheel', onScrollHandler);
42+
43+
return () => {
44+
elem.removeEventListener('wheel', onScrollHandler);
45+
};
46+
}, [onScrollHandler, ref]);
47+
}

packages/@react-aria/numberfield/src/useNumberField.ts

Lines changed: 6 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,7 @@ import {
1818
LabelHTMLAttributes,
1919
RefObject,
2020
useCallback,
21-
useEffect,
2221
useMemo,
23-
useRef,
2422
useState
2523
} from 'react';
2624
// @ts-ignore
@@ -29,12 +27,13 @@ import {isAndroid, isIOS, isIPhone, mergeProps, useId} from '@react-aria/utils';
2927
import {NumberFieldState} from '@react-stately/numberfield';
3028
import {TextInputDOMProps} from '@react-types/shared';
3129
import {useFocus, useFocusWithin} from '@react-aria/interactions';
30+
import {useFormattedTextField} from '@react-aria/textfield';
3231
import {
3332
useMessageFormatter,
3433
useNumberFormatter
3534
} from '@react-aria/i18n';
35+
import {useScrollWheel} from '@react-aria/interactions';
3636
import {useSpinButton} from '@react-aria/spinbutton';
37-
import {useTextField} from '@react-aria/textfield';
3837

3938
interface NumberFieldAria {
4039
/** Props for the label element. */
@@ -49,13 +48,6 @@ interface NumberFieldAria {
4948
decrementButtonProps: AriaButtonProps
5049
}
5150

52-
function supportsNativeBeforeInputEvent() {
53-
return typeof window !== 'undefined' &&
54-
window.InputEvent &&
55-
// @ts-ignore
56-
typeof InputEvent.prototype.getTargetRanges === 'function';
57-
}
58-
5951
/**
6052
* Provides the behavior and accessibility implementation for a number field component.
6153
* Number fields allow users to enter a number, and increment or decrement the value using stepper buttons.
@@ -120,14 +112,6 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt
120112
let {focusWithinProps} = useFocusWithin({isDisabled, onFocusWithinChange: setFocusWithin});
121113

122114
let onWheel = useCallback((e) => {
123-
// If the ctrlKey is pressed, this is a zoom event, do nothing.
124-
if (e.ctrlKey) {
125-
return;
126-
}
127-
128-
// stop scrolling the page
129-
e.preventDefault();
130-
131115
if (e.deltaY > 0) {
132116
increment();
133117
} else if (e.deltaY < 0) {
@@ -136,7 +120,7 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt
136120
}, [decrement, increment]);
137121
// If the input isn't supposed to receive input, disable scrolling.
138122
let scrollingDisabled = isDisabled || isReadOnly || !focusWithin;
139-
useScrollWheel({onScroll: onWheel, capture: false, isDisabled: scrollingDisabled}, inputRef);
123+
useScrollWheel({onScroll: onWheel, isDisabled: scrollingDisabled}, inputRef);
140124

141125
// The inputMode attribute influences the software keyboard that is shown on touch devices.
142126
// Browsers and operating systems are quite inconsistent about what keys are available, however.
@@ -166,96 +150,11 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt
166150
}
167151
}
168152

169-
let stateRef = useRef(state);
170-
stateRef.current = state;
171-
172-
// All browsers implement the 'beforeinput' event natively except Firefox
173-
// (currently behind a flag as of Firefox 84). React's polyfill does not
174-
// run in all cases that the native event fires, e.g. when deleting text.
175-
// Use the native event if available so that we can prevent invalid deletions.
176-
// We do not attempt to polyfill this in Firefox since it would be very complicated,
177-
// the benefit of doing so is fairly minor, and it's going to be natively supported soon.
178-
useEffect(() => {
179-
if (!supportsNativeBeforeInputEvent()) {
180-
return;
181-
}
182-
183-
let input = inputRef.current;
184-
185-
let onBeforeInput = (e: InputEvent) => {
186-
let state = stateRef.current;
187-
188-
// Compute the next value of the input if the event is allowed to proceed.
189-
// See https://www.w3.org/TR/input-events-2/#interface-InputEvent-Attributes for a full list of input types.
190-
let nextValue: string;
191-
switch (e.inputType) {
192-
case 'historyUndo':
193-
case 'historyRedo':
194-
// Explicitly allow undo/redo. e.data is null in this case, but there's no need to validate,
195-
// because presumably the input would have already been validated previously.
196-
return;
197-
case 'deleteContent':
198-
case 'deleteByCut':
199-
case 'deleteByDrag':
200-
nextValue = input.value.slice(0, input.selectionStart) + input.value.slice(input.selectionEnd);
201-
break;
202-
case 'deleteContentForward':
203-
// This is potentially incorrect, since the browser may actually delete more than a single UTF-16
204-
// character. In reality, a full Unicode grapheme cluster consisting of multiple UTF-16 characters
205-
// or code points may be deleted. However, in our currently supported locales, there are no such cases.
206-
// If we support additional locales in the future, this may need to change.
207-
nextValue = input.selectionEnd === input.selectionStart
208-
? input.value.slice(0, input.selectionStart) + input.value.slice(input.selectionEnd + 1)
209-
: input.value.slice(0, input.selectionStart) + input.value.slice(input.selectionEnd);
210-
break;
211-
case 'deleteContentBackward':
212-
nextValue = input.selectionEnd === input.selectionStart
213-
? input.value.slice(0, input.selectionStart - 1) + input.value.slice(input.selectionStart)
214-
: input.value.slice(0, input.selectionStart) + input.value.slice(input.selectionEnd);
215-
break;
216-
default:
217-
if (e.data != null) {
218-
nextValue =
219-
input.value.slice(0, input.selectionStart) +
220-
e.data +
221-
input.value.slice(input.selectionEnd);
222-
}
223-
break;
224-
}
225-
226-
// If we did not compute a value, or the new value is invalid, prevent the event
227-
// so that the browser does not update the input text, move the selection, or add to
228-
// the undo/redo stack.
229-
if (nextValue == null || !state.validate(nextValue)) {
230-
e.preventDefault();
231-
}
232-
};
233-
234-
input.addEventListener('beforeinput', onBeforeInput, false);
235-
return () => {
236-
input.removeEventListener('beforeinput', onBeforeInput, false);
237-
};
238-
}, [inputRef, stateRef]);
239-
240-
let onBeforeInput = !supportsNativeBeforeInputEvent()
241-
? e => {
242-
let nextValue =
243-
e.target.value.slice(0, e.target.selectionStart) +
244-
e.data +
245-
e.target.value.slice(e.target.selectionEnd);
246-
247-
if (!state.validate(nextValue)) {
248-
e.preventDefault();
249-
}
250-
}
251-
: null;
252-
253153
let onChange = value => {
254154
state.setInputValue(value);
255155
};
256156

257-
let compositionStartState = useRef(null);
258-
let {labelProps, inputProps: textFieldProps} = useTextField({
157+
let {labelProps, inputProps: textFieldProps} = useFormattedTextField({
259158
label,
260159
autoFocus,
261160
isDisabled,
@@ -269,35 +168,8 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt
269168
id: inputId,
270169
type: 'text', // Can't use type="number" because then we can't have things like $ in the field.
271170
inputMode,
272-
onChange,
273-
onBeforeInput,
274-
onCompositionStart() {
275-
// Chrome does not implement Input Events Level 2, which specifies the insertFromComposition
276-
// and deleteByComposition inputType values for the beforeinput event. These are meant to occur
277-
// at the end of a composition (e.g. Pinyin IME, Android auto correct, etc.), and crucially, are
278-
// cancelable. The insertCompositionText and deleteCompositionText input types are not cancelable,
279-
// nor would we want to cancel them because the input from the user is incomplete at that point.
280-
// In Safari, insertFromComposition/deleteFromComposition will fire, however, allowing us to cancel
281-
// the final composition result if it is invalid. As a fallback for Chrome and Firefox, which either
282-
// don't support Input Events Level 2, or beforeinput at all, we store the state of the input when
283-
// the compositionstart event fires, and undo the changes in compositionend (below) if it is invalid.
284-
// Unfortunately, this messes up the undo/redo stack, but until insertFromComposition/deleteByComposition
285-
// are implemented, there is no other way to prevent composed input.
286-
// See https://bugs.chromium.org/p/chromium/issues/detail?id=1022204
287-
let {value, selectionStart, selectionEnd} = inputRef.current;
288-
compositionStartState.current = {value, selectionStart, selectionEnd};
289-
},
290-
onCompositionEnd() {
291-
if (!state.validate(inputRef.current.value)) {
292-
// Restore the input value in the DOM immediately so we can synchronously update the selection position.
293-
// But also update the value in React state as well so it is correct for future updates.
294-
let {value, selectionStart, selectionEnd} = compositionStartState.current;
295-
inputRef.current.value = value;
296-
inputRef.current.setSelectionRange(selectionStart, selectionEnd);
297-
state.setInputValue(value);
298-
}
299-
}
300-
}, inputRef);
171+
onChange
172+
}, state, inputRef);
301173

302174
let inputProps = mergeProps(
303175
spinButtonProps,
@@ -388,20 +260,3 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt
388260
decrementButtonProps
389261
};
390262
}
391-
392-
// scroll wheel needs to be added not passively so it's cancelable, small helper hook to remember that
393-
function useScrollWheel({onScroll, capture, isDisabled}: {onScroll: (e) => void, capture: boolean, isDisabled: boolean}, ref: RefObject<HTMLElement>) {
394-
useEffect(() => {
395-
let elem = ref.current;
396-
397-
if (!isDisabled) {
398-
elem.addEventListener('wheel', onScroll, capture);
399-
}
400-
401-
return () => {
402-
if (!isDisabled) {
403-
elem.removeEventListener('wheel', onScroll, capture);
404-
}
405-
};
406-
}, [onScroll, ref, capture, isDisabled]);
407-
}

packages/@react-aria/textfield/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@
1111
*/
1212

1313
export * from './useTextField';
14+
export * from './useFormattedTextField';

0 commit comments

Comments
 (0)