diff --git a/packages/@react-aria/datepicker/src/useDateField.ts b/packages/@react-aria/datepicker/src/useDateField.ts index efb17f7c333..1820ae1019a 100644 --- a/packages/@react-aria/datepicker/src/useDateField.ts +++ b/packages/@react-aria/datepicker/src/useDateField.ts @@ -13,11 +13,11 @@ import {AriaDatePickerProps, AriaTimeFieldProps, DateValue, TimeValue} from '@react-types/datepicker'; import {createFocusManager, FocusManager} from '@react-aria/focus'; import {DateFieldState} from '@react-stately/datepicker'; -import {DOMAttributes} from '@react-types/shared'; +import {DOMAttributes, KeyboardEvent} from '@react-types/shared'; import {filterDOMProps, mergeProps, useDescription} from '@react-aria/utils'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {RefObject, useEffect, useMemo, useRef} from 'react'; +import {FocusEvent as ReactFocusEvent, RefObject, useEffect, useMemo, useRef} from 'react'; import {useDatePickerGroup} from './useDatePickerGroup'; import {useField} from '@react-aria/label'; import {useFocusWithin} from '@react-aria/interactions'; @@ -126,7 +126,48 @@ export function useDateField(props: AriaDateFieldProps, focusManager.focusFirst(); } }, - fieldProps: mergeProps(domProps, fieldDOMProps, groupProps, focusWithinProps), + fieldProps: mergeProps(domProps, fieldDOMProps, groupProps, focusWithinProps, { + onKeyDown(e: KeyboardEvent) { + if (props.onKeyDown) { + props.onKeyDown(e); + } + }, + onKeyUp(e: KeyboardEvent) { + if (props.onKeyUp) { + props.onKeyUp(e); + } + }, + onFocus(e: ReactFocusEvent) { + if (state.isFocused) { + return; + } + + if (props.onFocus) { + props.onFocus(e); + } + + if (props.onFocusChange) { + props.onFocusChange(true); + } + + state.setFocused(true); + }, + onBlur(e: ReactFocusEvent) { + if (e.currentTarget.contains(e.relatedTarget as Node)) { + return; + } + + if (props.onBlur) { + props.onBlur(e); + } + + if (props.onFocusChange) { + props.onFocusChange(false); + } + + state.setFocused(false); + } + }), descriptionProps, errorMessageProps }; diff --git a/packages/@react-aria/datepicker/src/useDatePicker.ts b/packages/@react-aria/datepicker/src/useDatePicker.ts index f4c43b29bfa..1bd406092c3 100644 --- a/packages/@react-aria/datepicker/src/useDatePicker.ts +++ b/packages/@react-aria/datepicker/src/useDatePicker.ts @@ -16,11 +16,11 @@ import {AriaDialogProps} from '@react-types/dialog'; import {CalendarProps} from '@react-types/calendar'; import {createFocusManager} from '@react-aria/focus'; import {DatePickerState} from '@react-stately/datepicker'; -import {DOMAttributes} from '@react-types/shared'; +import {DOMAttributes, KeyboardEvent} from '@react-types/shared'; import {filterDOMProps, mergeProps, useDescription, useId} from '@react-aria/utils'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {RefObject, useMemo} from 'react'; +import {FocusEvent as ReactFocusEvent, RefObject, useMemo} from 'react'; import {roleSymbol} from './useDateField'; import {useDatePickerGroup} from './useDatePickerGroup'; import {useField} from '@react-aria/label'; @@ -76,7 +76,63 @@ export function useDatePicker(props: AriaDatePickerProps role: 'group', 'aria-disabled': props.isDisabled || null, 'aria-labelledby': labelledBy, - 'aria-describedby': ariaDescribedBy + 'aria-describedby': ariaDescribedBy, + onKeyDown(e: KeyboardEvent) { + if (state.isOpen) { + return; + } + + if (props.onKeyDown) { + props.onKeyDown(e); + } + }, + onKeyUp(e: KeyboardEvent) { + if (state.isOpen) { + return; + } + + if (props.onKeyUp) { + props.onKeyUp(e); + } + }, + onFocus(e: ReactFocusEvent) { + if (state.isFocused) { + return; + } + + if (state.isOpen) { + return; + } + + if (props.onFocus) { + props.onFocus(e); + } + + if (props.onFocusChange) { + props.onFocusChange(true); + } + + state.setFocused(true); + }, + onBlur(e: ReactFocusEvent) { + if (state.isOpen) { + return; + } + + if (e.currentTarget.contains(e.relatedTarget as Node)) { + return; + } + + if (props.onBlur) { + props.onBlur(e); + } + + if (props.onFocusChange) { + props.onFocusChange(false); + } + + state.setFocused(false); + } }), labelProps: { ...labelProps, diff --git a/packages/@react-aria/datepicker/src/useDateRangePicker.ts b/packages/@react-aria/datepicker/src/useDateRangePicker.ts index e61d20b7b26..056c926783f 100644 --- a/packages/@react-aria/datepicker/src/useDateRangePicker.ts +++ b/packages/@react-aria/datepicker/src/useDateRangePicker.ts @@ -15,13 +15,13 @@ import {AriaDatePickerProps, AriaDateRangePickerProps, DateValue} from '@react-t import {AriaDialogProps} from '@react-types/dialog'; import {createFocusManager} from '@react-aria/focus'; import {DateRangePickerState} from '@react-stately/datepicker'; -import {DOMAttributes} from '@react-types/shared'; +import {DOMAttributes, KeyboardEvent} from '@react-types/shared'; import {filterDOMProps, mergeProps, useDescription, useId} from '@react-aria/utils'; import {focusManagerSymbol, roleSymbol} from './useDateField'; // @ts-ignore import intlMessages from '../intl/*.json'; import {RangeCalendarProps} from '@react-types/calendar'; -import {RefObject, useMemo} from 'react'; +import {FocusEvent as ReactFocusEvent, RefObject, useMemo} from 'react'; import {useDatePickerGroup} from './useDatePickerGroup'; import {useField} from '@react-aria/label'; import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; @@ -109,7 +109,63 @@ export function useDateRangePicker(props: AriaDateRangePick groupProps: mergeProps(domProps, groupProps, fieldProps, descProps, { role: 'group', 'aria-disabled': props.isDisabled || null, - 'aria-describedby': ariaDescribedBy + 'aria-describedby': ariaDescribedBy, + onKeyDown(e: KeyboardEvent) { + if (state.isOpen) { + return; + } + + if (props.onKeyDown) { + props.onKeyDown(e); + } + }, + onKeyUp(e: KeyboardEvent) { + if (state.isOpen) { + return; + } + + if (props.onKeyUp) { + props.onKeyUp(e); + } + }, + onFocus(e: ReactFocusEvent) { + if (state.isFocused) { + return; + } + + if (state.isOpen) { + return; + } + + if (props.onFocus) { + props.onFocus(e); + } + + if (props.onFocusChange) { + props.onFocusChange(true); + } + + state.setFocused(true); + }, + onBlur(e: ReactFocusEvent) { + if (state.isOpen) { + return; + } + + if (e.currentTarget.contains(e.relatedTarget as Node)) { + return; + } + + if (props.onBlur) { + props.onBlur(e); + } + + if (props.onFocusChange) { + props.onFocusChange(false); + } + + state.setFocused(false); + } }), labelProps: { ...labelProps, diff --git a/packages/@react-spectrum/datepicker/stories/DateField.stories.tsx b/packages/@react-spectrum/datepicker/stories/DateField.stories.tsx index 54ebd560f11..e71a858b4b0 100644 --- a/packages/@react-spectrum/datepicker/stories/DateField.stories.tsx +++ b/packages/@react-spectrum/datepicker/stories/DateField.stories.tsx @@ -124,6 +124,10 @@ storiesOf('Date and Time/DateField', module) .add( 'showFormatHelpText', () => render({showFormatHelpText: true}) + ) + .add( + 'all the events', + () => render({onBlur: action('onBlur'), onFocus: action('onFocus'), onFocusChange: action('onFocusChange'), onKeyDown: action('onKeyDown'), onKeyUp: action('onKeyUp'), onOpenChange: action('onOpenChange')}) ); storiesOf('Date and Time/DateField/styling', module) diff --git a/packages/@react-spectrum/datepicker/stories/DatePicker.stories.tsx b/packages/@react-spectrum/datepicker/stories/DatePicker.stories.tsx index c721377a420..f8af2f767b5 100644 --- a/packages/@react-spectrum/datepicker/stories/DatePicker.stories.tsx +++ b/packages/@react-spectrum/datepicker/stories/DatePicker.stories.tsx @@ -142,6 +142,10 @@ storiesOf('Date and Time/DatePicker', module) .add( 'showFormatHelpText', () => render({showFormatHelpText: true}) + ) + .add( + 'all the events', + () => render({onBlur: action('onBlur'), onFocus: action('onFocus'), onFocusChange: action('onFocusChange'), onKeyDown: action('onKeyDown'), onKeyUp: action('onKeyUp'), onOpenChange: action('onOpenChange')}) ); storiesOf('Date and Time/DatePicker/styling', module) diff --git a/packages/@react-spectrum/datepicker/stories/DateRangePicker.stories.tsx b/packages/@react-spectrum/datepicker/stories/DateRangePicker.stories.tsx index fb0fa3c7de7..e4ae9777712 100644 --- a/packages/@react-spectrum/datepicker/stories/DateRangePicker.stories.tsx +++ b/packages/@react-spectrum/datepicker/stories/DateRangePicker.stories.tsx @@ -128,6 +128,10 @@ storiesOf('Date and Time/DateRangePicker', module) .add( 'showFormatHelpText', () => render({showFormatHelpText: true}) + ) + .add( + 'all the events', + () => render({onBlur: action('onBlur'), onFocus: action('onFocus'), onFocusChange: action('onFocusChange'), onKeyDown: action('onKeyDown'), onKeyUp: action('onKeyUp'), onOpenChange: action('onOpenChange')}) ); storiesOf('Date and Time/DateRangePicker/styling', module) diff --git a/packages/@react-spectrum/datepicker/stories/TimeField.stories.tsx b/packages/@react-spectrum/datepicker/stories/TimeField.stories.tsx index 52c3bbc5acc..077dbe847b1 100644 --- a/packages/@react-spectrum/datepicker/stories/TimeField.stories.tsx +++ b/packages/@react-spectrum/datepicker/stories/TimeField.stories.tsx @@ -106,6 +106,10 @@ storiesOf('Date and Time/TimeField', module) .add( 'minValue: 8 AM, maxValue: 8 PM', () => render({minValue: new Time(8), maxValue: new Time(20)}) + ) + .add( + 'all the events', + () => render({onBlur: action('onBlur'), onFocus: action('onFocus'), onFocusChange: action('onFocusChange'), onKeyDown: action('onKeyDown'), onKeyUp: action('onKeyUp'), onOpenChange: action('onOpenChange')}) ); storiesOf('Date and Time/TimeField/styling', module) diff --git a/packages/@react-spectrum/datepicker/test/DateField.test.js b/packages/@react-spectrum/datepicker/test/DateField.test.js index 0bf21403e47..a8abf009a6e 100644 --- a/packages/@react-spectrum/datepicker/test/DateField.test.js +++ b/packages/@react-spectrum/datepicker/test/DateField.test.js @@ -10,12 +10,13 @@ * governing permissions and limitations under the License. */ -import {act, render as render_, within} from '@react-spectrum/test-utils'; +import {act, fireEvent, render as render_, within} from '@react-spectrum/test-utils'; import {CalendarDate} from '@internationalized/date'; import {DateField} from '../'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; import {theme} from '@react-spectrum/theme-default'; +import userEvent from '@testing-library/user-event'; function render(el) { if (el.type === Provider) { @@ -218,4 +219,85 @@ describe('DateField', function () { } }); }); + + describe('events', function () { + let onBlurSpy = jest.fn(); + let onFocusChangeSpy = jest.fn(); + let onFocusSpy = jest.fn(); + let onKeyDownSpy = jest.fn(); + let onKeyUpSpy = jest.fn(); + + afterEach(() => { + onBlurSpy.mockClear(); + onFocusChangeSpy.mockClear(); + onFocusSpy.mockClear(); + onKeyDownSpy.mockClear(); + onKeyUpSpy.mockClear(); + }); + + it('should focus field and switching segments via tab does not change focus', function () { + let {getAllByRole} = render(); + let segments = getAllByRole('spinbutton'); + + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).not.toHaveBeenCalled(); + expect(onFocusSpy).not.toHaveBeenCalled(); + + userEvent.tab(); + expect(segments[0]).toHaveFocus(); + + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + + userEvent.tab(); + expect(segments[1]).toHaveFocus(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + }); + + it('should call blur when focus leaves', function () { + let {getAllByRole} = render(); + let segments = getAllByRole('spinbutton'); + + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).not.toHaveBeenCalled(); + expect(onFocusSpy).not.toHaveBeenCalled(); + + userEvent.tab(); + expect(segments[0]).toHaveFocus(); + + userEvent.tab(); + expect(segments[1]).toHaveFocus(); + + userEvent.tab(); + expect(segments[2]).toHaveFocus(); + expect(onBlurSpy).toHaveBeenCalledTimes(0); + + userEvent.tab(); + expect(onBlurSpy).toHaveBeenCalledTimes(1); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(2); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + }); + + it('should trigger right arrow key event for segment navigation', function () { + let {getAllByRole} = render(); + let segments = getAllByRole('spinbutton'); + + expect(onKeyDownSpy).not.toHaveBeenCalled(); + expect(onKeyUpSpy).not.toHaveBeenCalled(); + + userEvent.tab(); + expect(segments[0]).toHaveFocus(); + expect(onKeyDownSpy).not.toHaveBeenCalled(); + expect(onKeyUpSpy).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowRight'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowRight'}); + expect(segments[1]).toHaveFocus(); + expect(onKeyDownSpy).toHaveBeenCalledTimes(1); + expect(onKeyUpSpy).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/packages/@react-spectrum/datepicker/test/DatePicker.test.js b/packages/@react-spectrum/datepicker/test/DatePicker.test.js index 57ae559db68..f567a5381f2 100644 --- a/packages/@react-spectrum/datepicker/test/DatePicker.test.js +++ b/packages/@react-spectrum/datepicker/test/DatePicker.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {act, fireEvent, render as render_, triggerPress, within} from '@react-spectrum/test-utils'; +import {act, fireEvent, render as render_, triggerPress, waitFor, within} from '@react-spectrum/test-utils'; import {CalendarDate, CalendarDateTime, EthiopicCalendar, getLocalTimeZone, JapaneseCalendar, toCalendarDateTime, today} from '@internationalized/date'; import {DatePicker} from '../'; import {Provider} from '@react-spectrum/provider'; @@ -185,6 +185,182 @@ describe('DatePicker', function () { }); }); + describe('events', function () { + let onBlurSpy = jest.fn(); + let onFocusChangeSpy = jest.fn(); + let onFocusSpy = jest.fn(); + let onKeyDownSpy = jest.fn(); + let onKeyUpSpy = jest.fn(); + + afterEach(() => { + onBlurSpy.mockClear(); + onFocusChangeSpy.mockClear(); + onFocusSpy.mockClear(); + onKeyDownSpy.mockClear(); + onKeyUpSpy.mockClear(); + }); + + it('should focus field, move a segment, and open popover and does not blur', function () { + let {getByRole, getAllByRole} = render(); + let segments = getAllByRole('spinbutton'); + let button = getByRole('button'); + + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).not.toHaveBeenCalled(); + expect(onFocusSpy).not.toHaveBeenCalled(); + + userEvent.tab(); + expect(segments[0]).toHaveFocus(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + + userEvent.tab(); + expect(segments[1]).toHaveFocus(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + + triggerPress(button); + act(() => jest.runAllTimers()); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + }); + + it('should focus field and leave to blur', function () { + let {getAllByRole} = render(); + let segments = getAllByRole('spinbutton'); + + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).not.toHaveBeenCalled(); + expect(onFocusSpy).not.toHaveBeenCalled(); + + userEvent.tab(); + expect(segments[0]).toHaveFocus(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + + userEvent.click(document.body); + expect(document.body).toHaveFocus(); + expect(onBlurSpy).toHaveBeenCalledTimes(1); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(2); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + }); + + it('should open popover and call picker onFocus', function () { + let {getByRole} = render(); + let button = getByRole('button'); + + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).not.toHaveBeenCalled(); + expect(onFocusSpy).not.toHaveBeenCalled(); + + triggerPress(button); + act(() => jest.runAllTimers()); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + }); + + it('should open and close popover and only call blur when focus leaves picker', async function () { + let {getByRole} = render(); + let button = getByRole('button'); + + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).not.toHaveBeenCalled(); + expect(onFocusSpy).not.toHaveBeenCalled(); + + triggerPress(button); + act(() => jest.runAllTimers()); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(document.activeElement, {key: 'Escape'}); + fireEvent.keyUp(document.activeElement, {key: 'Escape'}); + act(() => jest.runAllTimers()); + + await waitFor(() => { + expect(dialog).not.toBeInTheDocument(); + }); // wait for animation + + // now that it's been unmounted, run the raf callback + act(() => { + jest.runAllTimers(); + }); + + expect(dialog).not.toBeInTheDocument(); + expect(document.activeElement).toBe(button); + expect(button).toHaveFocus(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + + userEvent.tab(); + expect(document.body).toHaveFocus(); + expect(onBlurSpy).toHaveBeenCalledTimes(1); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(2); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + }); + + it('should trigger right arrow key event for segment navigation', function () { + let {getAllByRole} = render(); + let segments = getAllByRole('spinbutton'); + + expect(onKeyDownSpy).not.toHaveBeenCalled(); + expect(onKeyUpSpy).not.toHaveBeenCalled(); + + userEvent.tab(); + expect(segments[0]).toHaveFocus(); + expect(onKeyDownSpy).not.toHaveBeenCalled(); + expect(onKeyUpSpy).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowRight'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowRight'}); + expect(segments[1]).toHaveFocus(); + expect(onKeyDownSpy).toHaveBeenCalledTimes(1); + expect(onKeyUpSpy).toHaveBeenCalledTimes(2); + }); + + it('should trigger key event in popover and focus/blur/key events are not called', function () { + let {getByRole} = render(); + let button = getByRole('button'); + + expect(onKeyDownSpy).not.toHaveBeenCalled(); + expect(onKeyUpSpy).not.toHaveBeenCalled(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).not.toHaveBeenCalled(); + expect(onFocusSpy).not.toHaveBeenCalled(); + + triggerPress(button); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowRight'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowRight'}); + expect(onKeyDownSpy).toHaveBeenCalledTimes(0); + expect(onKeyUpSpy).toHaveBeenCalledTimes(0); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + }); + }); + describe('calendar popover', function () { it('should emit onChange when selecting a date in the calendar in controlled mode', function () { let onChange = jest.fn(); diff --git a/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js b/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js index 0237a47607a..a3ab185d72d 100644 --- a/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js +++ b/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {act, fireEvent, getAllByRole as getAllByRoleInContainer, render as render_, triggerPress, within} from '@react-spectrum/test-utils'; +import {act, fireEvent, getAllByRole as getAllByRoleInContainer, render as render_, triggerPress, waitFor, within} from '@react-spectrum/test-utils'; import {CalendarDate, CalendarDateTime, getLocalTimeZone, toCalendarDateTime, today} from '@internationalized/date'; import {DateRangePicker} from '../'; import {Provider} from '@react-spectrum/provider'; @@ -263,6 +263,182 @@ describe('DateRangePicker', function () { }); }); + describe('events', function () { + let onBlurSpy = jest.fn(); + let onFocusChangeSpy = jest.fn(); + let onFocusSpy = jest.fn(); + let onKeyDownSpy = jest.fn(); + let onKeyUpSpy = jest.fn(); + + afterEach(() => { + onBlurSpy.mockClear(); + onFocusChangeSpy.mockClear(); + onFocusSpy.mockClear(); + onKeyDownSpy.mockClear(); + onKeyUpSpy.mockClear(); + }); + + it('should focus field, move a segment, and open popover and does not blur', function () { + let {getByRole, getAllByRole} = render(); + let segments = getAllByRole('spinbutton'); + let button = getByRole('button'); + + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).not.toHaveBeenCalled(); + expect(onFocusSpy).not.toHaveBeenCalled(); + + userEvent.tab(); + expect(segments[0]).toHaveFocus(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + + userEvent.tab(); + expect(segments[1]).toHaveFocus(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + + triggerPress(button); + act(() => jest.runAllTimers()); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + }); + + it('should focus field and leave to blur', function () { + let {getAllByRole} = render(); + let segments = getAllByRole('spinbutton'); + + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).not.toHaveBeenCalled(); + expect(onFocusSpy).not.toHaveBeenCalled(); + + userEvent.tab(); + expect(segments[0]).toHaveFocus(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + + userEvent.click(document.body); + expect(document.body).toHaveFocus(); + expect(onBlurSpy).toHaveBeenCalledTimes(1); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(2); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + }); + + it('should open popover and call picker onFocus', function () { + let {getByRole} = render(); + let button = getByRole('button'); + + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).not.toHaveBeenCalled(); + expect(onFocusSpy).not.toHaveBeenCalled(); + + triggerPress(button); + act(() => jest.runAllTimers()); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + }); + + it('should open and close popover and only call blur when focus leaves picker', async function () { + let {getByRole} = render(); + let button = getByRole('button'); + + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).not.toHaveBeenCalled(); + expect(onFocusSpy).not.toHaveBeenCalled(); + + triggerPress(button); + act(() => jest.runAllTimers()); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(document.activeElement, {key: 'Escape'}); + fireEvent.keyUp(document.activeElement, {key: 'Escape'}); + act(() => jest.runAllTimers()); + + await waitFor(() => { + expect(dialog).not.toBeInTheDocument(); + }); // wait for animation + + // now that it's been unmounted, run the raf callback + act(() => { + jest.runAllTimers(); + }); + + expect(dialog).not.toBeInTheDocument(); + expect(document.activeElement).toBe(button); + expect(button).toHaveFocus(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + + userEvent.tab(); + expect(document.body).toHaveFocus(); + expect(onBlurSpy).toHaveBeenCalledTimes(1); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(2); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + }); + + it('should trigger right arrow key event for segment navigation', function () { + let {getAllByRole} = render(); + let segments = getAllByRole('spinbutton'); + + expect(onKeyDownSpy).not.toHaveBeenCalled(); + expect(onKeyUpSpy).not.toHaveBeenCalled(); + + userEvent.tab(); + expect(segments[0]).toHaveFocus(); + expect(onKeyDownSpy).not.toHaveBeenCalled(); + expect(onKeyUpSpy).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowRight'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowRight'}); + expect(segments[1]).toHaveFocus(); + expect(onKeyDownSpy).toHaveBeenCalledTimes(1); + expect(onKeyUpSpy).toHaveBeenCalledTimes(2); + }); + + it('should trigger key event in popover and focus/blur/key events are not called', function () { + let {getByRole} = render(); + let button = getByRole('button'); + + expect(onKeyDownSpy).not.toHaveBeenCalled(); + expect(onKeyUpSpy).not.toHaveBeenCalled(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).not.toHaveBeenCalled(); + expect(onFocusSpy).not.toHaveBeenCalled(); + + triggerPress(button); + + let dialog = getByRole('dialog'); + expect(dialog).toBeVisible(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowRight'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowRight'}); + expect(onKeyDownSpy).toHaveBeenCalledTimes(0); + expect(onKeyUpSpy).toHaveBeenCalledTimes(0); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + }); + }); + describe('calendar popover', function () { it('should emit onChange when selecting a date range in the calendar in uncontrolled mode', function () { let onChange = jest.fn(); diff --git a/packages/@react-spectrum/datepicker/test/TimeField.test.js b/packages/@react-spectrum/datepicker/test/TimeField.test.js index 5ff943b5cde..7c82a280923 100644 --- a/packages/@react-spectrum/datepicker/test/TimeField.test.js +++ b/packages/@react-spectrum/datepicker/test/TimeField.test.js @@ -10,12 +10,13 @@ * governing permissions and limitations under the License. */ -import {act, render as render_, within} from '@react-spectrum/test-utils'; +import {act, fireEvent, render as render_, within} from '@react-spectrum/test-utils'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; import {theme} from '@react-spectrum/theme-default'; import {Time} from '@internationalized/date'; import {TimeField} from '../'; +import userEvent from '@testing-library/user-event'; function render(el) { if (el.type === Provider) { @@ -94,4 +95,86 @@ describe('TimeField', function () { expect(segment).toHaveAttribute('aria-disabled', 'true'); } }); + + describe('events', function () { + let onBlurSpy = jest.fn(); + let onFocusChangeSpy = jest.fn(); + let onFocusSpy = jest.fn(); + let onKeyDownSpy = jest.fn(); + let onKeyUpSpy = jest.fn(); + + afterEach(() => { + onBlurSpy.mockClear(); + onFocusChangeSpy.mockClear(); + onFocusSpy.mockClear(); + onKeyDownSpy.mockClear(); + onKeyUpSpy.mockClear(); + }); + + it('should focus field and switching segments via tab does not change focus', function () { + let {getAllByRole} = render(); + let segments = getAllByRole('spinbutton'); + + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).not.toHaveBeenCalled(); + expect(onFocusSpy).not.toHaveBeenCalled(); + + userEvent.tab(); + expect(segments[0]).toHaveFocus(); + + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + + userEvent.tab(); + expect(segments[1]).toHaveFocus(); + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(1); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + }); + + it('should call blur when focus leaves', function () { + let {getAllByRole} = render(); + let segments = getAllByRole('spinbutton'); + + expect(onBlurSpy).not.toHaveBeenCalled(); + expect(onFocusChangeSpy).not.toHaveBeenCalled(); + expect(onFocusSpy).not.toHaveBeenCalled(); + + userEvent.tab(); + expect(segments[0]).toHaveFocus(); + + userEvent.tab(); + expect(segments[1]).toHaveFocus(); + expect(onBlurSpy).toHaveBeenCalledTimes(0); + + userEvent.tab(); + expect(segments[2]).toHaveFocus(); + expect(onBlurSpy).toHaveBeenCalledTimes(0); + + userEvent.tab(); + expect(onBlurSpy).toHaveBeenCalledTimes(1); + expect(onFocusChangeSpy).toHaveBeenCalledTimes(2); + expect(onFocusSpy).toHaveBeenCalledTimes(1); + }); + + it('should trigger right arrow key event for segment navigation', function () { + let {getAllByRole} = render(); + let segments = getAllByRole('spinbutton'); + + expect(onKeyDownSpy).not.toHaveBeenCalled(); + expect(onKeyUpSpy).not.toHaveBeenCalled(); + + userEvent.tab(); + expect(segments[0]).toHaveFocus(); + expect(onKeyDownSpy).not.toHaveBeenCalled(); + expect(onKeyUpSpy).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowRight'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowRight'}); + expect(segments[1]).toHaveFocus(); + expect(onKeyDownSpy).toHaveBeenCalledTimes(1); + expect(onKeyUpSpy).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/packages/@react-stately/datepicker/src/useDateFieldState.ts b/packages/@react-stately/datepicker/src/useDateFieldState.ts index 97315086ae4..1df371e756d 100644 --- a/packages/@react-stately/datepicker/src/useDateFieldState.ts +++ b/packages/@react-stately/datepicker/src/useDateFieldState.ts @@ -87,7 +87,11 @@ export interface DateFieldState { /** Clears the value of the given segment, reverting it to the placeholder. */ clearSegment(type: SegmentType): void, /** Formats the current date value using the given options. */ - formatValue(fieldOptions: FieldOptions): string + formatValue(fieldOptions: FieldOptions): string, + /** Whether the date field is currently focused. */ + readonly isFocused: boolean, + /** Sets whether the date field is focused. */ + setFocused(isFocused: boolean): void } const EDITABLE_SEGMENTS = { @@ -151,6 +155,8 @@ export function useDateFieldState(props: DateFieldStateOptions): DateFieldState let [granularity, defaultTimeZone] = useDefaultProps(v, props.granularity); let timeZone = defaultTimeZone || 'UTC'; + let [isFocused, setFocused] = useState(false); + // props.granularity must actually exist in the value if one is provided. if (v && !(granularity in v)) { throw new Error('Invalid granularity ' + granularity + ' for value ' + v.toString()); @@ -373,7 +379,9 @@ export function useDateFieldState(props: DateFieldStateOptions): DateFieldState let formatOptions = getFormatOptions(fieldOptions, formatOpts); let formatter = new DateFormatter(locale, formatOptions); return formatter.format(dateValue); - } + }, + isFocused, + setFocused }; } diff --git a/packages/@react-stately/datepicker/src/useDatePickerState.ts b/packages/@react-stately/datepicker/src/useDatePickerState.ts index f3ae0973746..2967f79f0c1 100644 --- a/packages/@react-stately/datepicker/src/useDatePickerState.ts +++ b/packages/@react-stately/datepicker/src/useDatePickerState.ts @@ -57,7 +57,11 @@ export interface DatePickerState extends OverlayTriggerState { /** The current validation state of the date picker, based on the `validationState`, `minValue`, and `maxValue` props. */ validationState: ValidationState, /** Formats the selected value using the given options. */ - formatValue(locale: string, fieldOptions: FieldOptions): string + formatValue(locale: string, fieldOptions: FieldOptions): string, + /** Whether the date picker is currently focused. */ + readonly isFocused: boolean, + /** Sets whether the date picker is focused. */ + setFocused(isFocused: boolean): void } /** @@ -68,6 +72,8 @@ export function useDatePickerState(props: DatePickerStateOptions): DatePickerSta let overlayState = useOverlayTriggerState(props); let [value, setValue] = useControlledState(props.value, props.defaultValue || null, props.onChange); + let [isFocused, setFocused] = useState(false); + let v = (value || props.placeholderValue); let [granularity, defaultTimeZone] = useDefaultProps(v, props.granularity); let dateValue = value != null ? value.toDate(defaultTimeZone ?? 'UTC') : null; @@ -159,6 +165,8 @@ export function useDatePickerState(props: DatePickerStateOptions): DatePickerSta let formatter = new DateFormatter(locale, formatOptions); return formatter.format(dateValue); - } + }, + isFocused, + setFocused }; } diff --git a/packages/@react-stately/datepicker/src/useDateRangePickerState.ts b/packages/@react-stately/datepicker/src/useDateRangePickerState.ts index 13333a37072..f5cde41b441 100644 --- a/packages/@react-stately/datepicker/src/useDateRangePickerState.ts +++ b/packages/@react-stately/datepicker/src/useDateRangePickerState.ts @@ -63,7 +63,11 @@ export interface DateRangePickerState extends OverlayTriggerState { /** The current validation state of the date picker, based on the `validationState`, `minValue`, and `maxValue` props. */ validationState: ValidationState, /** Formats the selected range using the given options. */ - formatValue(locale: string, fieldOptions: FieldOptions): {start: string, end: string} + formatValue(locale: string, fieldOptions: FieldOptions): {start: string, end: string}, + /** Whether the date range picker is currently focused. */ + readonly isFocused: boolean, + /** Sets whether the date range picker is focused. */ + setFocused(isFocused: boolean): void } /** @@ -76,6 +80,8 @@ export function useDateRangePickerState(props: DateRangePickerStateOptions): Dat let [controlledValue, setControlledValue] = useControlledState(props.value, props.defaultValue || null, props.onChange); let [placeholderValue, setPlaceholderValue] = useState(() => controlledValue || {start: null, end: null}); + let [isFocused, setFocused] = useState(false); + // Reset the placeholder if the value prop is set to null. if (controlledValue == null && placeholderValue.start && placeholderValue.end) { placeholderValue = {start: null, end: null}; @@ -263,6 +269,8 @@ export function useDateRangePickerState(props: DateRangePickerStateOptions): Dat start: startFormatter.format(startDate), end: endFormatter.format(endDate) }; - } + }, + isFocused, + setFocused }; }