diff --git a/packages/@react-spectrum/tooltip/stories/TooltipTrigger.stories.tsx b/packages/@react-spectrum/tooltip/stories/TooltipTrigger.stories.tsx index bcbdebd4b56..f3eec79b022 100644 --- a/packages/@react-spectrum/tooltip/stories/TooltipTrigger.stories.tsx +++ b/packages/@react-spectrum/tooltip/stories/TooltipTrigger.stories.tsx @@ -45,6 +45,12 @@ const argTypes = { max: 50000, step: 500 }, + closeDelay: { + control: 'number', + min: 0, + max: 50000, + step: 500 + }, offset: { control: 'number', min: -500, diff --git a/packages/@react-stately/tooltip/src/useTooltipTriggerState.ts b/packages/@react-stately/tooltip/src/useTooltipTriggerState.ts index 32227e8ba60..1d2af169d31 100644 --- a/packages/@react-stately/tooltip/src/useTooltipTriggerState.ts +++ b/packages/@react-stately/tooltip/src/useTooltipTriggerState.ts @@ -113,7 +113,11 @@ export function useTooltipTriggerState(props: TooltipTriggerProps = {}): Tooltip let warmupTooltip = () => { closeOpenTooltips(); ensureTooltipEntry(); - if (!isOpen && !globalWarmUpTimeout && !globalWarmedUp) { + if (!isOpen && !globalWarmedUp) { + if (globalWarmUpTimeout) { + clearTimeout(globalWarmUpTimeout); + } + globalWarmUpTimeout = setTimeout(() => { globalWarmUpTimeout = null; globalWarmedUp = true; diff --git a/packages/@react-stately/tooltip/test/useTooltipTriggerState.test.js b/packages/@react-stately/tooltip/test/useTooltipTriggerState.test.js index 6e84dd425d0..bb1f38ffe7d 100644 --- a/packages/@react-stately/tooltip/test/useTooltipTriggerState.test.js +++ b/packages/@react-stately/tooltip/test/useTooltipTriggerState.test.js @@ -53,7 +53,7 @@ function ManualTooltipTrigger(props) { props.onOpenChange(isOpen); setOpen(isOpen); }; - + return ( { onOpenChange.mockClear(); + fireEvent.keyDown(document.activeElement, {key: 'Escape'}); + fireEvent.keyUp(document.activeElement, {key: 'Escape'}); // there's global state, so we need to make sure to run out the cooldown for every test act(() => {jest.runAllTimers();}); }); @@ -214,6 +216,125 @@ describe('useTooltipTriggerState', function () { }); }); + describe('warmup delay', () => { + it('clears previous warmup timeout when open is called multiple times rapidly', () => { + let delay = 1000; + + function ManualTriggerComponent(props) { + let state = useTooltipTriggerState(props); + let ref = React.useRef(); + + let {triggerProps, tooltipProps} = useTooltipTrigger(props, state, ref); + + return ( + + + + + {state.isOpen && + {props.tooltip}} + + ); + } + + let {queryByRole, getByTestId} = render( + + Trigger + + ); + + fireEvent.mouseDown(document.body); + fireEvent.mouseUp(document.body); + + let manualOpenButton = getByTestId('manual-open'); + + // First call to open() - starts a warmup timer + fireEvent.click(manualOpenButton); + expect(queryByRole('tooltip')).toBeNull(); + + // Run 60% through the delay + act(() => jest.advanceTimersByTime(delay * 0.6)); + expect(queryByRole('tooltip')).toBeNull(); + + // Second call to open() - should clear previous timeout and start a new one + fireEvent.click(manualOpenButton); + + // If the old timeout wasn't cleared, the tooltip would open after just 400ms more + // But since it was cleared and restarted, we need the full 1000ms from the second call + act(() => jest.advanceTimersByTime(delay * 0.4)); + expect(queryByRole('tooltip')).toBeNull(); + expect(onOpenChange).not.toHaveBeenCalled(); + + // Advancing the remaining 600ms from the second trigger should open it + act(() => jest.advanceTimersByTime(delay * 0.6)); + expect(onOpenChange).toHaveBeenCalledWith(true); + expect(queryByRole('tooltip')).toBeVisible(); + }); + + it('does not open immediately when open() is called twice during warmup', () => { + function TooltipTriggerWithDoubleOpen(props) { + let state = useTooltipTriggerState(props); + let ref = React.useRef(); + + let {triggerProps, tooltipProps} = useTooltipTrigger(props, state, ref); + + let onMouseEnter = (e) => { + triggerProps.onMouseEnter?.(e); + state.open(false); + }; + + return ( + + + {state.isOpen && + {props.tooltip}} + + ); + } + + let delay = 1000; + + let {queryByRole, getByRole} = render( + + Trigger + + ); + + fireEvent.mouseDown(document.body); + fireEvent.mouseUp(document.body); + + let button = getByRole('button'); + + fireEvent.mouseEnter(button); + fireEvent.mouseMove(button); + + expect(onOpenChange).not.toHaveBeenCalled(); + expect(queryByRole('tooltip')).toBeNull(); + + // run halfway through the delay timer and confirm that it is still closed + act(() => jest.advanceTimersByTime(delay / 2)); + expect(queryByRole('tooltip')).toBeNull(); + + // run through the rest of the delay timer and confirm that it has opened + act(() => jest.advanceTimersByTime(delay / 2)); + expect(onOpenChange).toHaveBeenCalledWith(true); + expect(getByRole('tooltip')).toBeVisible(); + }); + }); + describe('multiple controlled tooltips', () => { it('closes previus tooltip when opening a new one', () => { let secondOnOpenChange = jest.fn(); diff --git a/packages/react-aria-components/src/DateField.tsx b/packages/react-aria-components/src/DateField.tsx index 112a7562a62..27721b1f525 100644 --- a/packages/react-aria-components/src/DateField.tsx +++ b/packages/react-aria-components/src/DateField.tsx @@ -57,7 +57,12 @@ export interface DateFieldRenderProps { * Whether the date field is read only. * @selector [data-readonly] */ - isReadOnly: boolean + isReadOnly: boolean, + /** + * Whether the date field is required. + * @selector [data-required] + */ + isRequired: boolean } export interface DateFieldProps extends Omit, 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RACValidation, RenderProps, SlotProps, GlobalDOMAttributes { /** @@ -113,7 +118,8 @@ export const DateField = /*#__PURE__*/ (forwardRef as forwardRefType)(function D state, isInvalid: state.isInvalid, isDisabled: state.isDisabled, - isReadOnly: state.isReadOnly + isReadOnly: state.isReadOnly, + isRequired: props.isRequired || false }, defaultClassName: 'react-aria-DateField' }); @@ -143,7 +149,8 @@ export const DateField = /*#__PURE__*/ (forwardRef as forwardRefType)(function D slot={props.slot || undefined} data-invalid={state.isInvalid || undefined} data-disabled={state.isDisabled || undefined} - data-readonly={state.isReadOnly || undefined} /> + data-readonly={state.isReadOnly || undefined} + data-required={props.isRequired || undefined} /> + data-readonly={state.isReadOnly || undefined} + data-required={props.isRequired || undefined} /> ); }); diff --git a/packages/react-aria-components/src/DatePicker.tsx b/packages/react-aria-components/src/DatePicker.tsx index 61a50dc6ae0..944dd7c3899 100644 --- a/packages/react-aria-components/src/DatePicker.tsx +++ b/packages/react-aria-components/src/DatePicker.tsx @@ -66,6 +66,11 @@ export interface DatePickerRenderProps { * @selector [data-invalid] */ isInvalid: boolean, + /** + * Whether the date picker is required. + * @selector [data-required] + */ + isRequired: boolean, /** * Whether the date picker's popover is currently open. * @selector [data-open] @@ -161,7 +166,8 @@ export const DatePicker = /*#__PURE__*/ (forwardRef as forwardRefType)(function isDisabled: props.isDisabled || false, isInvalid: state.isInvalid, isOpen: state.isOpen, - isReadOnly: props.isReadOnly || false + isReadOnly: props.isReadOnly || false, + isRequired: props.isRequired || false }, defaultClassName: 'react-aria-DatePicker' }); @@ -204,6 +210,7 @@ export const DatePicker = /*#__PURE__*/ (forwardRef as forwardRefType)(function data-focus-visible={isFocusVisible || undefined} data-disabled={props.isDisabled || undefined} data-readonly={props.isReadOnly || undefined} + data-required={props.isRequired || undefined} data-open={state.isOpen || undefined} /> ); diff --git a/packages/react-aria-components/src/Keyboard.tsx b/packages/react-aria-components/src/Keyboard.tsx index 8397fb4b2bd..27145470ff8 100644 --- a/packages/react-aria-components/src/Keyboard.tsx +++ b/packages/react-aria-components/src/Keyboard.tsx @@ -15,9 +15,9 @@ import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes} from 're export interface KeyboardProps extends HTMLAttributes, DOMRenderProps<'kbd', undefined> {} -export const KeyboardContext = createContext, HTMLElement>>({}); +export const KeyboardContext = createContext>({}); -export const Keyboard = forwardRef(function Keyboard(props: HTMLAttributes, ref: ForwardedRef) { +export const Keyboard = forwardRef(function Keyboard(props: KeyboardProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, KeyboardContext); return ; }); diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index b149d8f54ee..011aaf205a2 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {AriaListBoxOptions, AriaListBoxProps, DraggableItemResult, DragPreviewRenderer, DroppableCollectionResult, DroppableItemResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocus, useFocusRing, useHover, useListBox, useListBoxSection, useLocale, useOption} from 'react-aria'; +import {AriaListBoxOptions, AriaListBoxProps, DraggableItemResult, DragPreviewRenderer, DroppableCollectionResult, DroppableItemResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocus, useFocusRing, useHover, useKeyboard, useListBox, useListBoxSection, useLocale, useOption} from 'react-aria'; import { ClassNameOrFunction, ContextValue, @@ -33,7 +33,7 @@ import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPers import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableCollectionState, DroppableCollectionState, ListState, Node, Orientation, SelectionBehavior, UNSTABLE_useFilteredListState, useListState} from 'react-stately'; import {filterDOMProps, inertValue, LoadMoreSentinelProps, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils'; -import {FocusEvents, forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, PressEvents, RefObject} from '@react-types/shared'; +import {FocusEvents, forwardRefType, GlobalDOMAttributes, HoverEvents, Key, KeyboardEvents, LinkDOMProps, PressEvents, RefObject} from '@react-types/shared'; import {HeaderContext} from './Header'; import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; import {SelectableCollectionContext, SelectableCollectionContextValue} from './RSPContexts'; @@ -340,7 +340,7 @@ export const ListBoxSection = /*#__PURE__*/ createBranchComponent(SectionNode, L export interface ListBoxItemRenderProps extends ItemRenderProps {} -export interface ListBoxItemProps extends Omit, 'render'>, PossibleLinkDOMRenderProps<'div', ListBoxItemRenderProps>, LinkDOMProps, HoverEvents, PressEvents, FocusEvents, Omit, 'onClick'> { +export interface ListBoxItemProps extends Omit, 'render'>, PossibleLinkDOMRenderProps<'div', ListBoxItemRenderProps>, LinkDOMProps, HoverEvents, PressEvents, KeyboardEvents, FocusEvents, Omit, 'onClick'> { /** * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the element. A function may be provided to compute the class based on component state. * @default 'react-aria-ListBoxItem' @@ -383,6 +383,7 @@ export const ListBoxItem = /*#__PURE__*/ createLeafComponent(ItemNode, function onHoverEnd: item.props.onHoverEnd }); + let {keyboardProps} = useKeyboard(props); let {focusProps} = useFocus(props); let draggableItem: DraggableItemResult | null = null; @@ -431,7 +432,7 @@ export const ListBoxItem = /*#__PURE__*/ createLeafComponent(ItemNode, function return ( { expect(group).toHaveAttribute('data-disabled-state', 'disabled'); }); + it('should support required render prop', () => { + let {getByRole} = render( + + {({isRequired}) => ( + <> + + + {segment => } + + + )} + + ); + let group = getByRole('group'); + expect(group).toHaveAttribute('data-required-state', 'required'); + }); + + it('should support required state', () => { + let {getByRole, rerender} = render( + + + + {segment => } + + + ); + let group = getByRole('group'); + expect(group.closest('.react-aria-DateField')).not.toHaveAttribute('data-required'); + rerender( + + + + {segment => } + + + ); + expect(group.closest('.react-aria-DateField')).toHaveAttribute('data-required'); + }); + it('should support form value', () => { render( diff --git a/packages/react-aria-components/test/DatePicker.test.js b/packages/react-aria-components/test/DatePicker.test.js index 62d0ce8c462..80b0b15e2b5 100644 --- a/packages/react-aria-components/test/DatePicker.test.js +++ b/packages/react-aria-components/test/DatePicker.test.js @@ -158,6 +158,49 @@ describe('DatePicker', () => { expect(group).toHaveAttribute('data-validation-state', 'invalid'); }); + it('should support required render prop', () => { + let {getByRole} = render( + + {({isRequired}) => ( + <> + + + + {(segment) => } + + + + + + +
+ + + +
+ + {(date) => } + +
+
+
+ + )} +
+ ); + + let group = getByRole('group'); + expect(group).toHaveAttribute('data-required-state', 'required'); + }); + + it('should support required state', () => { + let {getByRole, rerender} = render(); + let group = getByRole('group'); + expect(group.closest('.react-aria-DatePicker')).not.toHaveAttribute('data-required'); + rerender(); + expect(group.closest('.react-aria-DatePicker')).toHaveAttribute('data-required'); + }); + it('should support form value', () => { render(); let input = document.querySelector('input[name=birthday]'); diff --git a/packages/react-aria-components/test/DateRangePicker.test.js b/packages/react-aria-components/test/DateRangePicker.test.js index 2dda3884105..50c05e0af3c 100644 --- a/packages/react-aria-components/test/DateRangePicker.test.js +++ b/packages/react-aria-components/test/DateRangePicker.test.js @@ -184,6 +184,53 @@ describe('DateRangePicker', () => { expect(group).toHaveAttribute('data-validation-state', 'invalid'); }); + it('should support required render prop', () => { + let {getByRole} = render( + + {({isRequired}) => ( + <> + + + + {(segment) => } + + + + {(segment) => } + + + + + + +
+ + + +
+ + {(date) => } + +
+
+
+ + )} +
+ ); + + let group = getByRole('group'); + expect(group).toHaveAttribute('data-required-state', 'required'); + }); + + it('should support required state', () => { + let {getByRole, rerender} = render(); + let group = getByRole('group'); + expect(group.closest('.react-aria-DateRangePicker')).not.toHaveAttribute('data-required'); + rerender(); + expect(group.closest('.react-aria-DateRangePicker')).toHaveAttribute('data-required'); + }); + it('should support form value', () => { render(); let start = document.querySelector('input[name=start]'); diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index 197a15e7ad5..18703102af0 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -1406,7 +1406,7 @@ describe('ListBox', () => { act(() => jest.runAllTimers()); expect(onReorder).toHaveBeenCalledTimes(1); - + // Verify we're no longer in drag mode options = getAllByRole('option'); expect(options.filter(opt => opt.classList.contains('react-aria-DropIndicator'))).toHaveLength(0); @@ -1949,6 +1949,28 @@ describe('ListBox', () => { }); }); + describe('onKeyDown', () => { + it('should call key handler when key is pressed on item', async () => { + let onKeyDown = jest.fn((e) => e.continuePropagation()); + let onKeyUp = jest.fn(); + let onSelectionChange = jest.fn(); + renderListbox({selectionMode: 'multiple', onSelectionChange}, {onKeyDown, onKeyUp}); + + await user.tab(); + expect(onKeyUp).toHaveBeenCalledTimes(1); + onKeyUp.mockClear(); + await user.keyboard('{Enter}'); + expect(onKeyDown).toHaveBeenCalledTimes(1); + expect(onKeyUp).toHaveBeenCalledTimes(1); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + + await user.keyboard('{Escape}'); + expect(onKeyDown).toHaveBeenCalledTimes(2); + expect(onKeyUp).toHaveBeenCalledTimes(2); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + }); + }); + if (React.version.startsWith('19')) { it('supports Activity', async () => { function ActivityListBox() { diff --git a/packages/react-aria-components/test/TimeField.test.js b/packages/react-aria-components/test/TimeField.test.js index 415c0d9e558..0b937f755d5 100644 --- a/packages/react-aria-components/test/TimeField.test.js +++ b/packages/react-aria-components/test/TimeField.test.js @@ -150,6 +150,46 @@ describe('TimeField', () => { expect(group).toHaveAttribute('data-disabled-state', 'disabled'); }); + it('should support required render prop', () => { + let {getByRole} = render( + + {({isRequired}) => ( + <> + + + {segment => } + + + )} + + ); + let group = getByRole('group'); + expect(group).toHaveAttribute('data-required-state', 'required'); + }); + + it('should support required state', () => { + let {getByRole, rerender} = render( + + + + {segment => } + + + ); + let group = getByRole('group'); + expect(group.closest('.react-aria-TimeField')).not.toHaveAttribute('data-required'); + rerender( + + + + {segment => } + + + ); + expect(group.closest('.react-aria-TimeField')).toHaveAttribute('data-required'); + }); + it('should support form value', () => { render(