diff --git a/packages/@react-aria/combobox/src/useComboBox.ts b/packages/@react-aria/combobox/src/useComboBox.ts index 6c7deae42ba..c7d97b5b7ec 100644 --- a/packages/@react-aria/combobox/src/useComboBox.ts +++ b/packages/@react-aria/combobox/src/useComboBox.ts @@ -16,7 +16,7 @@ import {AriaComboBoxProps} from '@react-types/combobox'; import {ariaHideOutside} from '@react-aria/overlays'; import {AriaListBoxOptions, getItemId, listData} from '@react-aria/listbox'; import {BaseEvent, DOMAttributes, KeyboardDelegate, LayoutDelegate, PressEvent, RefObject, RouterOptions, ValidationResult} from '@react-types/shared'; -import {chain, getActiveElement, getOwnerDocument, isAppleDevice, mergeProps, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils'; +import {chain, getActiveElement, getOwnerDocument, isAppleDevice, mergeProps, useInputEvent, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils'; import {ComboBoxState} from '@react-stately/combobox'; import {dispatchVirtualFocus} from '@react-aria/focus'; import {FocusEvent, InputHTMLAttributes, KeyboardEvent, TouchEvent, useEffect, useMemo, useRef} from 'react'; @@ -351,6 +351,8 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta } }, [focusedItem]); + useInputEvent(inputRef, state.onInputChange); + return { labelProps, buttonProps: { diff --git a/packages/@react-aria/radio/src/useRadio.ts b/packages/@react-aria/radio/src/useRadio.ts index bfee6c710b3..023a6a091ac 100644 --- a/packages/@react-aria/radio/src/useRadio.ts +++ b/packages/@react-aria/radio/src/useRadio.ts @@ -63,8 +63,7 @@ export function useRadio(props: AriaRadioProps, state: RadioGroupState, ref: Ref let checked = state.selectedValue === value; - let onChange = (e) => { - e.stopPropagation(); + let onChange = () => { state.setSelectedValue(value); }; diff --git a/packages/@react-aria/select/src/HiddenSelect.tsx b/packages/@react-aria/select/src/HiddenSelect.tsx index 332807812fc..67903664425 100644 --- a/packages/@react-aria/select/src/HiddenSelect.tsx +++ b/packages/@react-aria/select/src/HiddenSelect.tsx @@ -14,7 +14,7 @@ import {FocusableElement, RefObject} from '@react-types/shared'; import React, {InputHTMLAttributes, JSX, ReactNode, useCallback, useRef} from 'react'; import {selectData} from './useSelect'; import {SelectState} from '@react-stately/select'; -import {useFormReset} from '@react-aria/utils'; +import {useFormReset, useInputEvent} from '@react-aria/utils'; import {useFormValidation} from '@react-aria/form'; import {useVisuallyHidden} from '@react-aria/visually-hidden'; @@ -82,8 +82,14 @@ export function useHiddenSelect(props: AriaHiddenSelectOptions, state: Select focus: () => triggerRef.current?.focus() }, state, props.selectRef); - // eslint-disable-next-line react-hooks/exhaustive-deps - let onChange = useCallback((e: React.ChangeEvent | React.FormEvent) => state.setSelectedKey(e.currentTarget.value), [state.setSelectedKey]); + useInputEvent(props.selectRef!, state.onSelectionChange); + + let setSelectedKey = state.setSelectedKey; + let onChange = useCallback((e: React.ChangeEvent | React.FormEvent) => { + if (!e.nativeEvent['__reactAriaIgnore']) { + setSelectedKey(e.currentTarget.value); + } + }, [setSelectedKey]); // In Safari, the whereas other browsers diff --git a/packages/@react-aria/slider/src/useSliderThumb.ts b/packages/@react-aria/slider/src/useSliderThumb.ts index 5a609e4f78b..494187689cd 100644 --- a/packages/@react-aria/slider/src/useSliderThumb.ts +++ b/packages/@react-aria/slider/src/useSliderThumb.ts @@ -1,5 +1,5 @@ import {AriaSliderThumbProps} from '@react-types/slider'; -import {clamp, focusWithoutScrolling, mergeProps, useFormReset, useGlobalListeners} from '@react-aria/utils'; +import {clamp, focusWithoutScrolling, mergeProps, useFormReset, useGlobalListeners, useInputEvent} from '@react-aria/utils'; import {DOMAttributes, RefObject} from '@react-types/shared'; import {getSliderThumbId, sliderData} from './utils'; import React, {ChangeEvent, InputHTMLAttributes, LabelHTMLAttributes, useCallback, useEffect, useRef} from 'react'; @@ -232,6 +232,22 @@ export function useSliderThumb( state.setThumbValue(index, v); }); + let {onChange, onChangeEnd} = state; + useInputEvent(inputRef, useCallback(fn => { + return onChange((changedIndex, value) => { + if (index === changedIndex) { + fn(value); + } + }); + }, [onChange, index]), 'input'); + useInputEvent(inputRef, useCallback(fn => { + return onChangeEnd((changedIndex, value) => { + if (index === changedIndex) { + fn(value); + } + }); + }, [onChangeEnd, index]), 'change'); + // We install mouse handlers for the drag motion on the thumb div, but // not the key handler for moving the thumb with the slider. Instead, // we focus the range input, and let the browser handle the keyboard @@ -255,7 +271,9 @@ export function useSliderThumb( 'aria-describedby': [data['aria-describedby'], opts['aria-describedby']].filter(Boolean).join(' '), 'aria-details': [data['aria-details'], opts['aria-details']].filter(Boolean).join(' '), onChange: (e: ChangeEvent) => { - state.setThumbValue(index, parseFloat(e.target.value)); + if (!e.nativeEvent['__reactAriaIgnore']) { + state.setThumbValue(index, parseFloat(e.target.value)); + } } }), thumbProps: { diff --git a/packages/@react-aria/textfield/src/useTextField.ts b/packages/@react-aria/textfield/src/useTextField.ts index 8616abeb94b..5fa8a7401e5 100644 --- a/packages/@react-aria/textfield/src/useTextField.ts +++ b/packages/@react-aria/textfield/src/useTextField.ts @@ -206,7 +206,11 @@ export function useTextField) => setValue(e.target.value), + onChange: (e: ChangeEvent) => { + if (!e.nativeEvent['__reactAriaIgnore']) { + setValue(e.target.value); + } + }, autoComplete: props.autoComplete, autoCapitalize: props.autoCapitalize, maxLength: props.maxLength, diff --git a/packages/@react-aria/textfield/test/useTextField.test.js b/packages/@react-aria/textfield/test/useTextField.test.js index 40e3358b817..d0e420dcf66 100644 --- a/packages/@react-aria/textfield/test/useTextField.test.js +++ b/packages/@react-aria/textfield/test/useTextField.test.js @@ -84,6 +84,7 @@ describe('useTextField hook', () => { let onChange = jest.fn(); let props = renderTextFieldHook({onChange, 'aria-label': 'mandatory label'}); let mockEvent = { + nativeEvent: {}, target: { value: 1 } diff --git a/packages/@react-aria/toggle/src/useToggle.ts b/packages/@react-aria/toggle/src/useToggle.ts index 788c1b5e00f..36481da362c 100644 --- a/packages/@react-aria/toggle/src/useToggle.ts +++ b/packages/@react-aria/toggle/src/useToggle.ts @@ -58,9 +58,6 @@ export function useToggle(props: AriaToggleProps, state: ToggleState, ref: RefOb } = props; let onChange = (e) => { - // since we spread props on label, onChange will end up there as well as in here. - // so we have to stop propagation at the lowest level that we care about - e.stopPropagation(); state.setSelected(e.target.checked); }; diff --git a/packages/@react-aria/utils/src/index.ts b/packages/@react-aria/utils/src/index.ts index 463f76cf389..33e0e90ac65 100644 --- a/packages/@react-aria/utils/src/index.ts +++ b/packages/@react-aria/utils/src/index.ts @@ -51,5 +51,6 @@ export {CLEAR_FOCUS_EVENT, FOCUS_EVENT} from './constants'; export {isCtrlKeyPressed} from './keyboard'; export {useEnterAnimation, useExitAnimation} from './animation'; export {isFocusable, isTabbable} from './isFocusable'; +export {useInputEvent} from './useInputEvent'; export type {LoadMoreSentinelProps} from './useLoadMoreSentinel'; diff --git a/packages/@react-aria/utils/src/useInputEvent.ts b/packages/@react-aria/utils/src/useInputEvent.ts new file mode 100644 index 00000000000..c2d8529dc6d --- /dev/null +++ b/packages/@react-aria/utils/src/useInputEvent.ts @@ -0,0 +1,34 @@ +import {RefObject, useEffect} from 'react'; + +export function useInputEvent( + ref: RefObject, + subscribe: (fn: (value: T) => void) => () => void, + type?: 'input' | 'change' +): void { + useEffect(() => { + return subscribe(value => { + let el = ref.current; + if (el && window.event?.type !== 'reset' && (type === 'change' || el.value !== String(value ?? ''))) { + // Use native input element value property setter from the element's prototype. + // React overrides the setter directly on the element and ignores the input event. + // This matches more closely what the browser does natively (setter is not triggered). + // See https://stackoverflow.com/questions/23892547/what-is-the-best-way-to-trigger-change-or-input-event-in-react-js + let proto = Object.getPrototypeOf(el); + let setValue = Object.getOwnPropertyDescriptor(proto, 'value')!.set!; + setValue.call(el, String(value ?? '')); + + if (!type || type === 'input') { + let inputEvent = new Event('input', {bubbles: true}); + inputEvent['__reactAriaIgnore'] = true; + el.dispatchEvent(inputEvent); + } + + if (!type || type === 'change') { + let changeEvent = new Event('change', {bubbles: true}); + changeEvent['__reactAriaIgnore'] = true; + el.dispatchEvent(changeEvent); + } + } + }); + }, [ref, subscribe, type]); +} diff --git a/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js b/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js index 0ef2af2f80e..79adf124a4e 100644 --- a/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js +++ b/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js @@ -2512,6 +2512,8 @@ describe('SearchAutocomplete', function () { jest.runAllTimers(); }); + expect(input).toHaveAttribute('aria-describedby'); + let listbox = getByRole('listbox'); let items = within(listbox).getAllByRole('option'); await user.click(items[0]); @@ -2519,9 +2521,6 @@ describe('SearchAutocomplete', function () { jest.runAllTimers(); }); - expect(input).toHaveAttribute('aria-describedby'); - - await user.tab(); expect(input).not.toHaveAttribute('aria-describedby'); }); @@ -2550,6 +2549,8 @@ describe('SearchAutocomplete', function () { jest.runAllTimers(); }); + expect(input).toHaveAttribute('aria-describedby'); + let listbox = getByRole('listbox'); let items = within(listbox).getAllByRole('option'); await user.click(items[0]); @@ -2557,10 +2558,6 @@ describe('SearchAutocomplete', function () { jest.runAllTimers(); }); - expect(input).toHaveAttribute('aria-describedby'); - - await user.tab(); - expect(input).not.toHaveAttribute('aria-describedby'); expect(input.validity.valid).toBe(true); }); @@ -2603,6 +2600,8 @@ describe('SearchAutocomplete', function () { jest.runAllTimers(); }); + expect(input).toHaveAttribute('aria-describedby'); + let listbox = getByRole('listbox'); let items = within(listbox).getAllByRole('option'); await user.click(items[0]); @@ -2610,9 +2609,6 @@ describe('SearchAutocomplete', function () { jest.runAllTimers(); }); - expect(input).toHaveAttribute('aria-describedby'); - await user.tab(); - expect(input).not.toHaveAttribute('aria-describedby'); expect(input.validity.valid).toBe(true); }); diff --git a/packages/@react-spectrum/combobox/test/ComboBox.test.js b/packages/@react-spectrum/combobox/test/ComboBox.test.js index 8a6b7ee6e54..f2957be527f 100644 --- a/packages/@react-spectrum/combobox/test/ComboBox.test.js +++ b/packages/@react-spectrum/combobox/test/ComboBox.test.js @@ -5335,6 +5335,8 @@ describe('ComboBox', function () { jest.runAllTimers(); }); + expect(input).toHaveAttribute('aria-describedby'); + let listbox = getByRole('listbox'); let items = within(listbox).getAllByRole('option'); await user.click(items[0]); @@ -5342,9 +5344,6 @@ describe('ComboBox', function () { jest.runAllTimers(); }); - expect(input).toHaveAttribute('aria-describedby'); - - await user.tab(); expect(input).not.toHaveAttribute('aria-describedby'); }); @@ -5372,6 +5371,8 @@ describe('ComboBox', function () { jest.runAllTimers(); }); + expect(input).toHaveAttribute('aria-describedby'); + let listbox = getByRole('listbox'); let items = within(listbox).getAllByRole('option'); await user.click(items[0]); @@ -5379,10 +5380,6 @@ describe('ComboBox', function () { jest.runAllTimers(); }); - expect(input).toHaveAttribute('aria-describedby'); - - await user.tab(); - expect(input).not.toHaveAttribute('aria-describedby'); expect(input.validity.valid).toBe(true); }); @@ -5425,6 +5422,8 @@ describe('ComboBox', function () { jest.runAllTimers(); }); + expect(input).toHaveAttribute('aria-describedby'); + let listbox = getByRole('listbox'); let items = within(listbox).getAllByRole('option'); await user.click(items[0]); @@ -5432,9 +5431,6 @@ describe('ComboBox', function () { jest.runAllTimers(); }); - expect(input).toHaveAttribute('aria-describedby'); - await user.tab(); - expect(input).not.toHaveAttribute('aria-describedby'); expect(input.validity.valid).toBe(true); }); diff --git a/packages/@react-spectrum/picker/test/Picker.test.js b/packages/@react-spectrum/picker/test/Picker.test.js index 0a8f751db4e..dcb53367dde 100644 --- a/packages/@react-spectrum/picker/test/Picker.test.js +++ b/packages/@react-spectrum/picker/test/Picker.test.js @@ -1210,7 +1210,7 @@ describe('Picker', function () { it('supports controlled selection', async function () { let {getByRole} = render( - + One Two Three @@ -1219,7 +1219,9 @@ describe('Picker', function () { ); let picker = getByRole('button'); + let input = document.querySelector('select[name=test]'); expect(picker).toHaveTextContent('Two'); + expect(input).toHaveValue('two'); await user.click(picker); act(() => jest.runAllTimers()); @@ -1249,6 +1251,7 @@ describe('Picker', function () { act(() => jest.runAllTimers()); expect(document.activeElement).toBe(picker); expect(picker).toHaveTextContent('Two'); + expect(input).toHaveValue('two'); }); it('supports default selection', async function () { @@ -2109,7 +2112,6 @@ describe('Picker', function () { expect(input).toHaveValue('one'); }); - it('should support form prop', () => { render( @@ -2157,6 +2159,31 @@ describe('Picker', function () { }); } + it('onChange event bubbles to form', async function () { + let onChange = jest.fn(); + let {getByTestId, getByRole} = render( + +
+ + One + Two + Three + +
+
+ ); + let picker = getByTestId('picker'); + await user.click(picker); + act(() => jest.runAllTimers()); + + let listbox = getByRole('listbox'); + let items = within(listbox).getAllByRole('option'); + expect(items.length).toBe(3); + await user.click(items[1]); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.lastCall[0].target.value).toBe('two'); + }); + describe('validation', () => { describe('validationBehavior=native', () => { it('supports isRequired', async () => { diff --git a/packages/@react-stately/combobox/src/useComboBoxState.ts b/packages/@react-stately/combobox/src/useComboBoxState.ts index 8278ca9a05b..d69d84e923e 100644 --- a/packages/@react-stately/combobox/src/useComboBoxState.ts +++ b/packages/@react-stately/combobox/src/useComboBoxState.ts @@ -17,10 +17,10 @@ import {getChildNodes} from '@react-stately/collections'; import {ListCollection, useSingleSelectListState} from '@react-stately/list'; import {SelectState} from '@react-stately/select'; import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {useControlledState} from '@react-stately/utils'; +import {useControlledState, useStateEvent} from '@react-stately/utils'; import {useOverlayTriggerState} from '@react-stately/overlays'; -export interface ComboBoxState extends SelectState, FormValidationState{ +export interface ComboBoxState extends Omit, 'onSelectionChange'>, FormValidationState{ /** The current value of the combo box input. */ inputValue: string, /** The default value of the combo box input. */ @@ -36,7 +36,9 @@ export interface ComboBoxState extends SelectState, FormValidationState{ /** Toggles the menu. */ toggle(focusStrategy?: FocusStrategy | null, trigger?: MenuTriggerAction): void, /** Resets the input value to the previously selected item's text if any and closes the menu. */ - revert(): void + revert(): void, + /** Registers a function that is called when the input value changes. */ + onInputChange(fn: (value: string) => void): () => void } type FilterFn = (textValue: string, inputValue: string) => boolean; @@ -61,7 +63,8 @@ export function useComboBoxState(props: ComboBoxStateOptions(props: ComboBoxStateOptions(); let [inputValue, setInputValue] = useControlledState( props.inputValue, getDefaultInputValue(props.defaultInputValue, selectedKey, collection) || '', - props.onInputChange + useCallback(value => { + emitInputChange(value); + onInputChange?.(value); + }, [onInputChange, emitInputChange]) ); let [initialSelectedKey] = useState(selectedKey); let [initialValue] = useState(inputValue); @@ -342,7 +349,7 @@ export function useComboBoxState(props: ComboBoxStateOptions(props: ComboBoxStateOptions extends Omit, 'children'>, CollectionStateBase {} @@ -36,7 +37,10 @@ export interface SelectState extends SingleSelectListState, OverlayTrigger open(focusStrategy?: FocusStrategy | null): void, /** Toggles the menu. */ - toggle(focusStrategy?: FocusStrategy | null): void + toggle(focusStrategy?: FocusStrategy | null): void, + + /** Registers a function that is called when the user changes the selected key. */ + onSelectionChange(fn: (key: Key | null) => void): () => void } /** @@ -47,6 +51,7 @@ export interface SelectState extends SingleSelectListState, OverlayTrigger export function useSelectState(props: SelectStateOptions): SelectState { let triggerState = useOverlayTriggerState(props); let [focusStrategy, setFocusStrategy] = useState(null); + let [subscribe, emit] = useStateEvent(); let listState = useSingleSelectListState({ ...props, onSelectionChange: (key) => { @@ -54,6 +59,7 @@ export function useSelectState(props: SelectStateOptions): props.onSelectionChange(key); } + emit(key); triggerState.close(); validationState.commitValidation(); } @@ -87,6 +93,7 @@ export function useSelectState(props: SelectStateOptions): } }, isFocused, - setFocused + setFocused, + onSelectionChange: subscribe }; } diff --git a/packages/@react-stately/slider/src/useSliderState.ts b/packages/@react-stately/slider/src/useSliderState.ts index 0f7350fc9a5..865aff4b4c4 100644 --- a/packages/@react-stately/slider/src/useSliderState.ts +++ b/packages/@react-stately/slider/src/useSliderState.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {clamp, snapValueToStep, useControlledState} from '@react-stately/utils'; +import {clamp, snapValueToStep, useControlledState, useStateEvent} from '@react-stately/utils'; import {Orientation} from '@react-types/shared'; import {SliderProps} from '@react-types/slider'; import {useCallback, useMemo, useRef, useState} from 'react'; @@ -146,7 +146,12 @@ export interface SliderState { readonly orientation: Orientation, /** Whether the slider is disabled. */ - readonly isDisabled: boolean + readonly isDisabled: boolean, + + /** Registers a function that is called when a thumb value changes. */ + onChange(fn: (index: number, value: number) => void): () => void, + /** Registers a function that is called when a thumb value ends dragging. */ + onChangeEnd(fn: (index: number, value: number) => void): () => void } const DEFAULT_MIN_VALUE = 0; @@ -188,6 +193,8 @@ export function useSliderState(props: SliderStateOp let value = useMemo(() => restrictValues(convertValue(props.value)), [props.value, restrictValues]); let defaultValue = useMemo(() => restrictValues(convertValue(props.defaultValue) ?? [minValue])!, [props.defaultValue, minValue, restrictValues]); + let [subscribeOnChange, emitOnChange] = useStateEvent<[number, number]>(); + let [subscribeOnChangeEnd, emitOnChangeEnd] = useStateEvent<[number, number]>(); let onChange = createOnChange(props.value, props.defaultValue, props.onChange); let onChangeEnd = createOnChange(props.value, props.defaultValue, props.onChangeEnd); @@ -244,6 +251,7 @@ export function useSliderState(props: SliderStateOp value = snapValueToStep(value, thisMin, thisMax, step); let newValues = replaceIndex(valuesRef.current, index, value); setValues(newValues); + emitOnChange(index, value); } function updateDragging(index: number, dragging: boolean) { @@ -257,6 +265,9 @@ export function useSliderState(props: SliderStateOp const wasDragging = isDraggingsRef.current[index]; isDraggingsRef.current = replaceIndex(isDraggingsRef.current, index, dragging); setDraggings(isDraggingsRef.current); + if (wasDragging) { + emitOnChangeEnd(index, valuesRef.current[index]); + } // Call onChangeEnd if no handles are dragging. if (onChangeEnd && wasDragging && !isDraggingsRef.current.some(Boolean)) { @@ -315,7 +326,9 @@ export function useSliderState(props: SliderStateOp step, pageSize, orientation, - isDisabled + isDisabled, + onChange: subscribeOnChange, + onChangeEnd: subscribeOnChangeEnd }; } diff --git a/packages/@react-stately/utils/src/index.ts b/packages/@react-stately/utils/src/index.ts index ff98cc62647..dd9e649aefb 100644 --- a/packages/@react-stately/utils/src/index.ts +++ b/packages/@react-stately/utils/src/index.ts @@ -11,3 +11,4 @@ */ export {useControlledState} from './useControlledState'; export {clamp, snapValueToStep, toFixedNumber} from './number'; +export {useStateEvent} from './useStateEvent'; diff --git a/packages/@react-stately/utils/src/useStateEvent.ts b/packages/@react-stately/utils/src/useStateEvent.ts new file mode 100644 index 00000000000..eb14b085b23 --- /dev/null +++ b/packages/@react-stately/utils/src/useStateEvent.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {useCallback, useRef} from 'react'; + +type Fn = (...args: T) => void +type Subscribe = (fn: Fn) => () => void;; + +export function useStateEvent(): [Subscribe, Fn] { + let subscriptions = useRef(new Set>()); + + let subscribe = useCallback((fn: Fn) => { + subscriptions.current.add(fn); + return () => subscriptions.current.delete(fn); + }, []); + + let emit = useCallback((...args: T) => { + for (let fn of subscriptions.current) { + fn(...args); + } + }, []); + + return [subscribe, emit]; +} diff --git a/packages/react-aria-components/test/Checkbox.test.js b/packages/react-aria-components/test/Checkbox.test.js index 5f36b9165aa..9b18eb4154e 100644 --- a/packages/react-aria-components/test/Checkbox.test.js +++ b/packages/react-aria-components/test/Checkbox.test.js @@ -277,4 +277,20 @@ describe('Checkbox', () => { let checkbox = getByRole('checkbox'); expect(checkbox).toHaveAttribute('form', 'test'); }); + + it('should allow onChange event to bubble to the form', async () => { + let onChange = jest.fn(); + + let {getByRole} = render( +
+ Test +
+ ); + + let checkbox = getByRole('checkbox'); + await user.click(checkbox); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.lastCall[0].target.name).toBe('test'); + }); }); diff --git a/packages/react-aria-components/test/ColorSlider.test.js b/packages/react-aria-components/test/ColorSlider.test.js index 455ff5e87bc..b6ff7ce7169 100644 --- a/packages/react-aria-components/test/ColorSlider.test.js +++ b/packages/react-aria-components/test/ColorSlider.test.js @@ -192,4 +192,33 @@ describe('ColorSlider', () => { let input = getByRole('slider'); expect(input).toHaveAttribute('form', 'test'); }); + + it('onChange event bubbles to form', async () => { + let onChange = jest.fn(); + let onInput = jest.fn(); + let onChangeNative = jest.fn(); + let ref = React.createRef(); + render( +
+ + + ); + + ref.current.addEventListener('change', onChangeNative); + + let track = document.querySelector('.react-aria-SliderTrack'); + await user.pointer([{target: track, keys: '[MouseLeft>]', coords: {x: 20}}]); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onInput).toHaveBeenCalledTimes(1); + expect(onChangeNative).toHaveBeenCalledTimes(0); + + await user.pointer([{target: track, keys: '[/MouseLeft]', coords: {x: 20}}]); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onInput).toHaveBeenCalledTimes(1); + expect(onChangeNative).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/react-aria-components/test/ComboBox.test.js b/packages/react-aria-components/test/ComboBox.test.js index 8b3a2401ff9..5306cbaf081 100644 --- a/packages/react-aria-components/test/ComboBox.test.js +++ b/packages/react-aria-components/test/ComboBox.test.js @@ -259,13 +259,12 @@ describe('ComboBox', () => { }); await user.keyboard('C'); - let options = comboboxTester.options(); - await user.click(options[0]); - expect(combobox).toHaveAttribute('aria-describedby'); expect(combobox.validity.valid).toBe(true); - await user.tab(); + let options = comboboxTester.options(); + await user.click(options[0]); + expect(combobox).not.toHaveAttribute('aria-describedby'); expect(combobox).not.toHaveAttribute('data-invalid'); }); @@ -384,4 +383,34 @@ describe('ComboBox', () => { let input = getByRole('combobox'); expect(input).toHaveAttribute('form', 'test'); }); + + it('should emit onChange event when selecting an item', async () => { + let onChange = jest.fn(); + let onInput = jest.fn(); + let onChangeNative = jest.fn(); + let ref = React.createRef(); + + render( +
+ + + ); + + ref.current.addEventListener('change', onChangeNative); + + await user.tab(); + await user.keyboard('Ca'); + + expect(onInput).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChangeNative).toHaveBeenCalledTimes(0); + + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); + + expect(onInput).toHaveBeenCalledTimes(3); + expect(onChange).toHaveBeenCalledTimes(3); + expect(onChangeNative).toHaveBeenCalledTimes(1); + expect(onChange.mock.lastCall[0].target.value).toBe('Cat'); + }); }); diff --git a/packages/react-aria-components/test/RadioGroup.test.js b/packages/react-aria-components/test/RadioGroup.test.js index e4f38253a55..521b09588b4 100644 --- a/packages/react-aria-components/test/RadioGroup.test.js +++ b/packages/react-aria-components/test/RadioGroup.test.js @@ -614,4 +614,21 @@ describe('RadioGroup', () => { expect(radio).toHaveAttribute('form', 'test'); } }); + + it('should allow onChange event to bubble to the form', async () => { + let onChange = jest.fn(); + + let {getAllByRole} = render( +
+ + + ); + + let radios = getAllByRole('radio'); + await user.click(radios[1]); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.lastCall[0].target.name).toBe('test'); + expect(onChange.mock.lastCall[0].target.value).toBe('b'); + }); }); diff --git a/packages/react-aria-components/test/Slider.test.js b/packages/react-aria-components/test/Slider.test.js index 888cbcc8d9d..41036bb0df8 100644 --- a/packages/react-aria-components/test/Slider.test.js +++ b/packages/react-aria-components/test/Slider.test.js @@ -298,5 +298,34 @@ describe('Slider', () => { let input = getByRole('slider'); expect(input).toHaveAttribute('form', 'test'); }); + + it('onChange event bubbles to form', async () => { + let onChange = jest.fn(); + let onInput = jest.fn(); + let onChangeNative = jest.fn(); + let ref = React.createRef(); + render( +
+ + + ); + + ref.current.addEventListener('change', onChangeNative); + + let track = document.querySelector('.react-aria-SliderTrack'); + await user.pointer([{target: track, keys: '[MouseLeft>]', coords: {x: 20}}]); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onInput).toHaveBeenCalledTimes(1); + expect(onChangeNative).toHaveBeenCalledTimes(0); + + await user.pointer([{target: track, keys: '[/MouseLeft]', coords: {x: 20}}]); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onInput).toHaveBeenCalledTimes(1); + expect(onChangeNative).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/react-aria-components/test/Switch.test.js b/packages/react-aria-components/test/Switch.test.js index 41389f2fb86..dc52adf36a0 100644 --- a/packages/react-aria-components/test/Switch.test.js +++ b/packages/react-aria-components/test/Switch.test.js @@ -262,4 +262,20 @@ describe('Switch', () => { let input = getByRole('switch'); expect(input).toHaveAttribute('form', 'test'); }); + + it('should allow onChange event to bubble to the form', async () => { + let onChange = jest.fn(); + + let {getByRole} = render( +
+ Test +
+ ); + + let input = getByRole('switch'); + await user.click(input); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.lastCall[0].target.name).toBe('test'); + }); }); diff --git a/packages/react-aria-components/test/TextField.test.js b/packages/react-aria-components/test/TextField.test.js index a5d354ee4c7..b3e8d63f356 100644 --- a/packages/react-aria-components/test/TextField.test.js +++ b/packages/react-aria-components/test/TextField.test.js @@ -266,5 +266,21 @@ describe('TextField', () => { let input = getByRole('textbox'); expect(input).toHaveAttribute('form', 'test'); }); + + it('should allow onChange event to bubble to the form', async () => { + let onChange = jest.fn(); + + render( +
+ + + ); + + await user.tab(); + await user.keyboard('Hi'); + + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange.mock.lastCall[0].target.name).toBe('test'); + }); }); }); diff --git a/yarn.lock b/yarn.lock index 59babe681c0..09c55dfd993 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8235,6 +8235,7 @@ __metadata: "@react-stately/form": "npm:^3.1.5" "@react-stately/list": "npm:^3.12.3" "@react-stately/overlays": "npm:^3.6.17" + "@react-stately/utils": "npm:^3.10.7" "@react-types/select": "npm:^3.9.13" "@react-types/shared": "npm:^3.30.0" "@swc/helpers": "npm:^0.5.0"