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
139 changes: 96 additions & 43 deletions packages/@react-aria/overlays/src/calculatePosition.ts

Large diffs are not rendered by default.

25 changes: 22 additions & 3 deletions packages/@react-aria/overlays/test/calculatePosition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,14 @@ describe('calculatePosition', function () {
// The tests are all based on top/left positioning. Convert to bottom/right positioning if needed.
let pos: {right?: number, top?: number, left?: number, bottom?: number} = {};
if ((placementAxis === 'left' && !flip) || (placementAxis === 'right' && flip)) {
pos.right = boundaryDimensions.width - (expected[0] + overlaySize.width);
pos.right = containerDimensions.width - (expected[0] + overlaySize.width);
pos.top = expected[1];
} else if ((placementAxis === 'right' && !flip) || (placementAxis === 'left' && flip)) {
pos.left = expected[0];
pos.top = expected[1];
} else if (placementAxis === 'top') {
pos.left = expected[0];
pos.bottom = boundaryDimensions.height - providerOffset - (expected[1] + overlaySize.height);
pos.bottom = containerDimensions.height - (expected[1] + overlaySize.height);
} else if (placementAxis === 'bottom') {
pos.left = expected[0];
pos.top = expected[1];
Expand All @@ -138,13 +138,16 @@ describe('calculatePosition', function () {
};

const container = createElementWithDimensions('div', containerDimensions);
Object.assign(container.style, {
position: 'relative'
});
const target = createElementWithDimensions('div', targetDimension);
const overlay = createElementWithDimensions('div', overlaySize, margins);

const parentElement = document.createElement('div');
parentElement.appendChild(container);
parentElement.appendChild(target);
parentElement.appendChild(overlay);
container.appendChild(overlay);

document.documentElement.appendChild(parentElement);

Expand Down Expand Up @@ -330,6 +333,22 @@ describe('calculatePosition', function () {

testCases.forEach(function (testCase) {
const {placement} = testCase;
beforeEach(() => {
window.visualViewport = {
offsetTop: 0,
height: 600,
offsetLeft: 0,
scale: 1,
width: 0,
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => true,
onresize: () => {},
onscroll: () => {},
pageLeft: 0,
pageTop: 0
} as VisualViewport;
});

describe(`placement = ${placement}`, function () {
describe('no viewport offset', function () {
Expand Down
69 changes: 51 additions & 18 deletions packages/@react-aria/overlays/test/useOverlayPosition.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import {fireEvent, render} from '@react-spectrum/test-utils-internal';
import React, {useRef} from 'react';
import {useOverlayPosition} from '../';

function Example({triggerTop = 250, ...props}) {

function Example({triggerTop = 250, containerStyle = {width: 600, height: 600} as React.CSSProperties, ...props}) {
let targetRef = useRef(null);
let containerRef = useRef(null);
let overlayRef = useRef(null);
Expand All @@ -23,7 +24,7 @@ function Example({triggerTop = 250, ...props}) {
return (
<React.Fragment>
<div ref={targetRef} data-testid="trigger" style={{left: 10, top: triggerTop, width: 100, height: 100}}>Trigger</div>
<div ref={containerRef} data-testid="container" style={{width: 600, height: 600}}>
<div ref={containerRef} data-testid="container" style={containerStyle}>
<div ref={overlayRef} data-testid="overlay" style={style}>
<div data-testid="arrow" {...arrowProps} />
placement: {placement}
Expand All @@ -36,6 +37,13 @@ function Example({triggerTop = 250, ...props}) {
let original = window.HTMLElement.prototype.getBoundingClientRect;
HTMLElement.prototype.getBoundingClientRect = function () {
let rect = original.apply(this);
if (this.tagName === 'BODY') {
return {
...rect,
height: this.clientHeight,
width: this.clientWidth
};
}
return {
...rect,
left: parseInt(this.style.left, 10) || 0,
Expand All @@ -49,6 +57,21 @@ HTMLElement.prototype.getBoundingClientRect = function () {

describe('useOverlayPosition', function () {
beforeEach(() => {
window.visualViewport = {
offsetTop: 0,
height: 768,
offsetLeft: 0,
scale: 1,
width: 500,
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => true,
onresize: () => {},
onscroll: () => {},
pageLeft: 0,
pageTop: 0
} as VisualViewport;
document.body.style.margin = '0'; // jsdom defaults to having a margin of 8px, we should fix this down the line
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {configurable: true, value: 768});
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {configurable: true, value: 500});

Expand Down Expand Up @@ -89,7 +112,7 @@ describe('useOverlayPosition', function () {
position: absolute;
z-index: 100000;
left: 12px;
bottom: 518px;
bottom: 350px;
max-height: 238px;
`);

Expand All @@ -112,6 +135,7 @@ describe('useOverlayPosition', function () {
expect(overlay).toHaveTextContent('placement: bottom');

Object.defineProperty(HTMLElement.prototype, 'clientHeight', {configurable: true, value: 1000});
Object.defineProperty(window.visualViewport, 'height', {configurable: true, value: 1000});
fireEvent(window, new Event('resize'));

expect(overlay).toHaveStyle(`
Expand Down Expand Up @@ -226,6 +250,21 @@ describe('useOverlayPosition with positioned container', () => {
let realGetBoundingClientRect = window.HTMLElement.prototype.getBoundingClientRect;
let realGetComputedStyle = window.getComputedStyle;
beforeEach(() => {
window.visualViewport = {
offsetTop: 0,
height: 768,
offsetLeft: 0,
scale: 1,
width: 500,
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => true,
onresize: () => {},
onscroll: () => {},
pageLeft: 0,
pageTop: 0
} as VisualViewport;
document.body.style.margin = '0';
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {configurable: true, value: 768});
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {configurable: true, value: 500});
stubs.push(
Expand All @@ -238,19 +277,7 @@ describe('useOverlayPosition with positioned container', () => {
}
}),
jest.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.attributes.getNamedItem('data-testid')?.value === 'container') {
// Say, overlay is positioned somewhere
let real = realGetBoundingClientRect.apply(this);
return {
...real,
top: 150,
left: 0,
width: 400,
height: 400
};
} else {
return realGetBoundingClientRect.apply(this);
}
return realGetBoundingClientRect.apply(this);
}),
jest.spyOn(window, 'getComputedStyle').mockImplementation(element => {
if (element.attributes.getNamedItem('data-testid')?.value === 'container') {
Expand All @@ -260,6 +287,12 @@ describe('useOverlayPosition with positioned container', () => {
} else {
return realGetComputedStyle(element);
}
}),
jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) {
return parseInt(this.style.width, 10) || 0;
}),
jest.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) {
return parseInt(this.style.height, 10) || 0;
})
);
});
Expand All @@ -270,7 +303,7 @@ describe('useOverlayPosition with positioned container', () => {
});

it('should position the overlay relative to the trigger', function () {
let res = render(<Example />);
let res = render(<Example containerStyle={{top: 150, left: 0, width: 400, height: 400}} />);
let overlay = res.getByTestId('overlay');
let arrow = res.getByTestId('arrow');

Expand All @@ -291,7 +324,7 @@ describe('useOverlayPosition with positioned container', () => {
});

it('should position the overlay relative to the trigger at top', function () {
let res = render(<Example placement="top" />);
let res = render(<Example placement="top" containerStyle={{top: 150, left: 0, width: 400, height: 400}} />);
let overlay = res.getByTestId('overlay');
let arrow = res.getByTestId('arrow');

Expand Down
7 changes: 6 additions & 1 deletion packages/@react-aria/tooltip/src/useTooltipTrigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ export interface TooltipTriggerAria {
export function useTooltipTrigger(props: TooltipTriggerProps, state: TooltipTriggerState, ref: RefObject<FocusableElement | null>) : TooltipTriggerAria {
let {
isDisabled,
trigger
trigger,
closeOnPress = true
} = props;

let tooltipId = useId();
Expand Down Expand Up @@ -102,6 +103,10 @@ export function useTooltipTrigger(props: TooltipTriggerProps, state: TooltipTrig
};

let onPressStart = () => {
// if closeOnPress is false, we should not close the tooltip
if (!closeOnPress) {
return;
}
// no matter how the trigger is pressed, we should close the tooltip
isFocused.current = false;
isHovered.current = false;
Expand Down
7 changes: 5 additions & 2 deletions packages/@react-spectrum/tooltip/src/TooltipTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,16 @@ import {useTooltipTriggerState} from '@react-stately/tooltip';

const DEFAULT_OFFSET = -1; // Offset needed to reach 4px/5px (med/large) distance between tooltip and trigger button
const DEFAULT_CROSS_OFFSET = 0;
const DEFAULT_CLOSE_ON_PRESS = true; // Whether the tooltip should close when the trigger is pressed

function TooltipTrigger(props: SpectrumTooltipTriggerProps) {
let {
children,
crossOffset = DEFAULT_CROSS_OFFSET,
isDisabled,
offset = DEFAULT_OFFSET,
trigger: triggerAction
trigger: triggerAction,
closeOnPress = DEFAULT_CLOSE_ON_PRESS
} = props;

let [trigger, tooltip] = React.Children.toArray(children) as [ReactElement, ReactElement];
Expand All @@ -40,7 +42,8 @@ function TooltipTrigger(props: SpectrumTooltipTriggerProps) {

let {triggerProps, tooltipProps} = useTooltipTrigger({
isDisabled,
trigger: triggerAction
trigger: triggerAction,
closeOnPress
}, state, tooltipTriggerRef);

let [borderRadius, setBorderRadius] = useState(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ const argTypes = {
},
children: {
control: {disable: true}
},
closeOnPress: {
control: 'boolean'
}
};

Expand Down Expand Up @@ -113,7 +116,8 @@ export default {
<ActionButton aria-label="Edit Name"><Edit /></ActionButton>,
<Tooltip>Change Name</Tooltip>
],
onOpenChange: action('openChange')
onOpenChange: action('openChange'),
closeOnPress: true
},
argTypes: argTypes
} as Meta<typeof TooltipTrigger>;
Expand Down
42 changes: 42 additions & 0 deletions packages/@react-spectrum/tooltip/test/TooltipTrigger.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,48 @@ describe('TooltipTrigger', function () {
expect(queryByRole('tooltip')).toBeNull();
});

it('does not close if the trigger is clicked when closeOnPress is false', async () => {
let {getByRole, getByLabelText} = render(
<Provider theme={theme}>
<TooltipTrigger onOpenChange={onOpenChange} delay={0} closeOnPress={false}>
<ActionButton aria-label="trigger" />
<Tooltip>Helpful information.</Tooltip>
</TooltipTrigger>
</Provider>
);
await user.click(document.body);

let button = getByLabelText('trigger');
await user.hover(button);
expect(onOpenChange).toHaveBeenCalledWith(true);
let tooltip = getByRole('tooltip');
expect(tooltip).toBeVisible();
await user.click(button);
expect(onOpenChange).toHaveBeenCalledTimes(1);
expect(tooltip).toBeVisible();
});

it('does not close if the trigger is clicked with the keyboard when closeOnPress is false', async () => {
let {getByRole, getByLabelText} = render(
<Provider theme={theme}>
<TooltipTrigger onOpenChange={onOpenChange} delay={0} closeOnPress={false}>
<ActionButton aria-label="trigger" />
<Tooltip>Helpful information.</Tooltip>
</TooltipTrigger>
</Provider>
);

let button = getByLabelText('trigger');
await user.tab();
expect(onOpenChange).toHaveBeenCalledWith(true);
let tooltip = getByRole('tooltip');
expect(tooltip).toBeVisible();
fireEvent.keyDown(button, {key: 'Enter'});
fireEvent.keyUp(button, {key: 'Enter'});
expect(onOpenChange).toHaveBeenCalledTimes(1);
expect(tooltip).toBeVisible();
});

describe('delay', () => {
it('opens immediately for focus', () => {
let {getByRole, getByLabelText} = render(
Expand Down
4 changes: 2 additions & 2 deletions packages/@react-stately/select/src/useSelectState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,13 +162,13 @@ export function useSelectState<T extends object, M extends SelectionMode = 'sing
focusStrategy,
open(focusStrategy: FocusStrategy | null = null) {
// Don't open if the collection is empty.
if (listState.collection.size !== 0) {
if (listState.collection.size !== 0 || props.allowsEmptyCollection) {
setFocusStrategy(focusStrategy);
triggerState.open();
}
},
toggle(focusStrategy: FocusStrategy | null = null) {
if (listState.collection.size !== 0) {
if (listState.collection.size !== 0 || props.allowsEmptyCollection) {
setFocusStrategy(focusStrategy);
triggerState.toggle();
}
Expand Down
4 changes: 3 additions & 1 deletion packages/@react-types/select/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ export interface SelectProps<T, M extends SelectionMode = 'single'> extends Coll
/** Sets the default open state of the menu. */
defaultOpen?: boolean,
/** Method that is called when the open state of the menu changes. */
onOpenChange?: (isOpen: boolean) => void
onOpenChange?: (isOpen: boolean) => void,
/** Whether the select should be allowed to be open when the collection is empty. */
allowsEmptyCollection?: boolean
}

export interface AriaSelectProps<T, M extends SelectionMode = 'single'> extends SelectProps<T, M>, DOMProps, AriaLabelingProps, FocusableDOMProps {
Expand Down
8 changes: 7 additions & 1 deletion packages/@react-types/tooltip/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@ export interface TooltipTriggerProps extends OverlayTriggerProps {
* By default, opens for both focus and hover. Can be made to open only for focus.
* @default 'hover'
*/
trigger?: 'hover' | 'focus'
trigger?: 'hover' | 'focus',

/**
* Whether the tooltip should close when the trigger is pressed.
* @default true
*/
closeOnPress?: boolean
}

export interface SpectrumTooltipTriggerProps extends Omit<TooltipTriggerProps, 'closeDelay'>, PositionProps {
Expand Down
Loading
Loading