Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ const argTypes = {
max: 50000,
step: 500
},
closeDelay: {
control: 'number',
min: 0,
max: 50000,
step: 500
},
offset: {
control: 'number',
min: -500,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
123 changes: 122 additions & 1 deletion packages/@react-stately/tooltip/test/useTooltipTriggerState.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ function ManualTooltipTrigger(props) {
props.onOpenChange(isOpen);
setOpen(isOpen);
};

return (
<TooltipTrigger
label={props.label}
Expand All @@ -77,6 +77,8 @@ describe('useTooltipTriggerState', function () {

afterEach(() => {
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();});
});
Expand Down Expand Up @@ -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 (
<span>
<button
ref={ref}
{...triggerProps}
data-testid="trigger-button">
{props.children}
</button>
<button
data-testid="manual-open"
onClick={() => state.open(false)}>
Manual Open
</button>
<button
data-testid="manual-close"
onClick={() => state.close(true)}>
Manual Close
</button>
{state.isOpen &&
<span role="tooltip" {...tooltipProps}>{props.tooltip}</span>}
</span>
);
}

let {queryByRole, getByTestId} = render(
<ManualTriggerComponent onOpenChange={onOpenChange} delay={delay} tooltip="Helpful information">
Trigger
</ManualTriggerComponent>
);

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 (
<span>
<button ref={ref} {...triggerProps} onMouseEnter={onMouseEnter}>{props.children}</button>
{state.isOpen &&
<span role="tooltip" {...tooltipProps}>{props.tooltip}</span>}
</span>
);
}

let delay = 1000;

let {queryByRole, getByRole} = render(
<TooltipTriggerWithDoubleOpen onOpenChange={onOpenChange} delay={delay} tooltip="Helpful information">
Trigger
</TooltipTriggerWithDoubleOpen>
);

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();
Expand Down
19 changes: 14 additions & 5 deletions packages/react-aria-components/src/DateField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends DateValue> extends Omit<AriaDateFieldProps<T>, 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RACValidation, RenderProps<DateFieldRenderProps>, SlotProps, GlobalDOMAttributes<HTMLDivElement> {
/**
Expand Down Expand Up @@ -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'
});
Expand Down Expand Up @@ -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} />
<HiddenDateInput
autoComplete={props.autoComplete}
name={props.name}
Expand Down Expand Up @@ -186,7 +193,8 @@ export const TimeField = /*#__PURE__*/ (forwardRef as forwardRefType)(function T
state,
isInvalid: state.isInvalid,
isDisabled: state.isDisabled,
isReadOnly: state.isReadOnly
isReadOnly: state.isReadOnly,
isRequired: props.isRequired || false
},
defaultClassName: 'react-aria-TimeField'
});
Expand Down Expand Up @@ -216,7 +224,8 @@ export const TimeField = /*#__PURE__*/ (forwardRef as forwardRefType)(function T
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} />
</Provider>
);
});
Expand Down
13 changes: 11 additions & 2 deletions packages/react-aria-components/src/DatePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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'
});
Expand Down Expand Up @@ -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} />
<HiddenDateInput
autoComplete={props.autoComplete}
Expand Down Expand Up @@ -271,7 +278,8 @@ export const DateRangePicker = /*#__PURE__*/ (forwardRef as forwardRefType)(func
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-DateRangePicker'
});
Expand Down Expand Up @@ -319,6 +327,7 @@ export const DateRangePicker = /*#__PURE__*/ (forwardRef as forwardRefType)(func
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} />
</Provider>
);
Expand Down
4 changes: 2 additions & 2 deletions packages/react-aria-components/src/Keyboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes} from 're

export interface KeyboardProps extends HTMLAttributes<HTMLElement>, DOMRenderProps<'kbd', undefined> {}

export const KeyboardContext = createContext<ContextValue<HTMLAttributes<HTMLElement>, HTMLElement>>({});
export const KeyboardContext = createContext<ContextValue<KeyboardProps, HTMLElement>>({});

export const Keyboard = forwardRef(function Keyboard(props: HTMLAttributes<HTMLElement>, ref: ForwardedRef<HTMLElement>) {
export const Keyboard = forwardRef(function Keyboard(props: KeyboardProps, ref: ForwardedRef<HTMLElement>) {
[props, ref] = useContextProps(props, ref, KeyboardContext);
return <dom.kbd dir="ltr" {...props} ref={ref} />;
});
9 changes: 5 additions & 4 deletions packages/react-aria-components/src/ListBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -340,7 +340,7 @@ export const ListBoxSection = /*#__PURE__*/ createBranchComponent(SectionNode, L

export interface ListBoxItemRenderProps extends ItemRenderProps {}

export interface ListBoxItemProps<T = object> extends Omit<RenderProps<ListBoxItemRenderProps>, 'render'>, PossibleLinkDOMRenderProps<'div', ListBoxItemRenderProps>, LinkDOMProps, HoverEvents, PressEvents, FocusEvents<HTMLDivElement>, Omit<GlobalDOMAttributes<HTMLDivElement>, 'onClick'> {
export interface ListBoxItemProps<T = object> extends Omit<RenderProps<ListBoxItemRenderProps>, 'render'>, PossibleLinkDOMRenderProps<'div', ListBoxItemRenderProps>, LinkDOMProps, HoverEvents, PressEvents, KeyboardEvents, FocusEvents<HTMLDivElement>, Omit<GlobalDOMAttributes<HTMLDivElement>, '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'
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -431,7 +432,7 @@ export const ListBoxItem = /*#__PURE__*/ createLeafComponent(ItemNode, function

return (
<ElementType
{...mergeProps(DOMProps, renderProps, optionProps, hoverProps, focusProps, draggableItem?.dragProps, droppableItem?.dropProps)}
{...mergeProps(DOMProps, renderProps, optionProps, hoverProps, keyboardProps, focusProps, draggableItem?.dragProps, droppableItem?.dropProps)}
ref={ref}
data-allows-dragging={!!dragState || undefined}
data-selected={states.isSelected || undefined}
Expand Down
40 changes: 40 additions & 0 deletions packages/react-aria-components/test/DateField.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,46 @@ describe('DateField', () => {
expect(group).toHaveAttribute('data-disabled-state', 'disabled');
});

it('should support required render prop', () => {
let {getByRole} = render(
<DateField isRequired>
{({isRequired}) => (
<>
<Label>Birth date</Label>
<DateInput
data-required-state={isRequired ? 'required' : null}>
{segment => <DateSegment segment={segment} />}
</DateInput>
</>
)}
</DateField>
);
let group = getByRole('group');
expect(group).toHaveAttribute('data-required-state', 'required');
});

it('should support required state', () => {
let {getByRole, rerender} = render(
<DateField>
<Label>Birth date</Label>
<DateInput>
{segment => <DateSegment segment={segment} />}
</DateInput>
</DateField>
);
let group = getByRole('group');
expect(group.closest('.react-aria-DateField')).not.toHaveAttribute('data-required');
rerender(
<DateField isRequired>
<Label>Birth date</Label>
<DateInput>
{segment => <DateSegment segment={segment} />}
</DateInput>
</DateField>
);
expect(group.closest('.react-aria-DateField')).toHaveAttribute('data-required');
});

it('should support form value', () => {
render(
<DateField name="birthday" form="test" value={new CalendarDate(2020, 2, 3)}>
Expand Down
Loading
Loading