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 @@ -51,6 +51,14 @@ governing permissions and limitations under the License.
overflow: hidden;
}

/* Prevent clicks during exit animation (e.g. Picker selecting wrong item).
pointer-events is not inherited, so we must disable it on the popover and all descendants. */
.spectrum-Popover.is-exiting,
.spectrum-Popover.is-exiting * {
pointer-events: none;
touch-action: none;
}

.is-open {
composes: spectrum-overlay--open;
}
Expand Down
19 changes: 14 additions & 5 deletions packages/@react-aria/calendar/src/useRangeCalendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import {useRef} from 'react';
* A range calendar displays one or more date grids and allows users to select a contiguous range of dates.
*/
export function useRangeCalendar<T extends DateValue>(props: AriaRangeCalendarProps<T>, state: RangeCalendarState, ref: RefObject<FocusableElement | null>): CalendarAria {
let res = useCalendarBase(props, state);
let {interactOutsideBehavior = 'select', ...otherProps} = props;
let res = useCalendarBase(otherProps, state);

// We need to ignore virtual pointer events from VoiceOver due to these bugs.
// https://bugs.webkit.org/show_bug.cgi?id=222627
Expand All @@ -36,7 +37,13 @@ export function useRangeCalendar<T extends DateValue>(props: AriaRangeCalendarPr
isVirtualClick.current = e.width === 0 && e.height === 0;
});

// Stop range selection when pressing or releasing a pointer outside the calendar body,
const interactOutsideBehaviorMapping = {
clear: () => state.clearSelection(),
reset: () => state.setAnchorDate(null),
select: () => state.selectFocusedDate()
};

// Execute method corresponding to `interactOutsideBehavior` when pressing or releasing a pointer outside the calendar body,
// except when pressing the next or previous buttons to switch months.
let endDragging = (e: PointerEvent) => {
if (isVirtualClick.current) {
Expand All @@ -55,19 +62,21 @@ export function useRangeCalendar<T extends DateValue>(props: AriaRangeCalendarPr
isFocusWithin(ref.current) &&
(!nodeContains(ref.current, target) || !target.closest('button, [role="button"]'))
) {
state.selectFocusedDate();
interactOutsideBehaviorMapping[interactOutsideBehavior]();
}
};

useEvent(windowRef, 'pointerup', endDragging);

// Also stop range selection on blur, e.g. tabbing away from the calendar.
// Also execute method corresponding to `interactOutsideBehavior` on blur,
// e.g. tabbing away from the calendar.
res.calendarProps.onBlur = e => {
if (!ref.current) {
return;
}

if ((!e.relatedTarget || !nodeContains(ref.current, e.relatedTarget)) && state.anchorDate) {
state.selectFocusedDate();
interactOutsideBehaviorMapping[interactOutsideBehavior]();
}
};

Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/interactions/src/useHover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ function setGlobalIgnoreEmulatedMouseEvents() {
// the distant future because a user previously touched the element.
setTimeout(() => {
globalIgnoreEmulatedMouseEvents = false;
}, 50);
}, 500);
}

function handleGlobalPointerEvent(e: PointerEvent) {
Expand Down
6 changes: 3 additions & 3 deletions packages/@react-aria/interactions/test/useHover.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ describe('useHover', function () {
fireEvent(el, pointerEvent('pointerout', {pointerType: 'touch'}));
fireEvent(el, pointerEvent('pointerup', {pointerType: 'touch'}));

act(() => {jest.advanceTimersByTime(100);});
act(() => {jest.advanceTimersByTime(600);});

// Safari on iOS has a bug that fires a pointer event with pointerType="mouse" on focus.
// See https://bugs.webkit.org/show_bug.cgi?id=214609.
Expand Down Expand Up @@ -321,7 +321,7 @@ describe('useHover', function () {
let el = res.getByTestId('test');
fireEvent(el, pointerEvent('pointerover', {pointerType: 'mouse'}));
expect(el).toHaveAttribute('data-hovered', 'true');

let button = res.getByRole('button');
act(() => button.click());
expect(button).not.toBeInTheDocument();
Expand Down Expand Up @@ -444,7 +444,7 @@ describe('useHover', function () {
fireEvent.mouseLeave(el);
fireEvent.touchEnd(el);

act(() => {jest.advanceTimersByTime(100);});
act(() => {jest.advanceTimersByTime(600);});

// Safari on iOS has a bug that fires a mouse event on focus.
// See https://bugs.webkit.org/show_bug.cgi?id=214609.
Expand Down
8 changes: 6 additions & 2 deletions packages/@react-aria/overlays/src/ariaHideOutside.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import {shadowDOM} from '@react-stately/flags';

const supportsInert = typeof HTMLElement !== 'undefined' && 'inert' in HTMLElement.prototype;

function isAlwaysVisibleNode(node: HTMLElement | SVGElement): boolean {
return node.dataset.liveAnnouncer === 'true' || node.dataset.reactAriaTopLayer !== undefined;
}

interface AriaHideOutsideOptions {
root?: Element,
shouldUseInert?: boolean
Expand Down Expand Up @@ -175,7 +179,7 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt
for (let node of change.addedNodes) {
if (
(node instanceof HTMLElement || node instanceof SVGElement) &&
(node.dataset.liveAnnouncer === 'true' || node.dataset.reactAriaTopLayer === 'true')
isAlwaysVisibleNode(node)
) {
visibleNodes.add(node);
} else if (node instanceof Element) {
Expand Down Expand Up @@ -218,7 +222,7 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt
for (let node of change.addedNodes) {
if (
(node instanceof HTMLElement || node instanceof SVGElement) &&
(node.dataset.liveAnnouncer === 'true' || node.dataset.reactAriaTopLayer === 'true')
isAlwaysVisibleNode(node)
) {
visibleNodes.add(node);
} else if (node instanceof Element) {
Expand Down
29 changes: 29 additions & 0 deletions packages/@react-aria/overlays/test/ariaHideOutside.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,35 @@ describe('ariaHideOutside', function () {
expect(getAllByRole('checkbox')).toHaveLength(1);
});

it('should recognize dynamically added top layer element with data-react-aria-top-layer="" (attribute presence)', async function () {
let Test = props => (
<>
<button>Button</button>
{props.show && (
<>
<div role="status" data-react-aria-top-layer="">Top layer (empty attr)</div>
<input type="checkbox" />
</>
)}
</>
);

let {getByRole, getAllByRole, rerender} = render(<Test />);

let button = getByRole('button');
let revert = ariaHideOutside([button]);

rerender(<Test show />);

// MutationObserver is async
await Promise.resolve();
expect(getByRole('status')).not.toHaveAttribute('aria-hidden');
expect(getByRole('checkbox', {hidden: true})).toHaveAttribute('aria-hidden', 'true');

revert();
expect(getAllByRole('checkbox')).toHaveLength(1);
});

it('should handle when a new element is added inside a target element', async function () {
let Test = props => (
<>
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/select/src/HiddenSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ export function HiddenSelect<T, M extends SelectionMode = 'single'>(props: Hidde
<label>
{label}
<select {...selectProps} ref={selectRef}>
<option />
<option value="" label={'\u00A0'}>{'\u00A0'}</option>
{[...state.collection.getKeys()].map(key => {
let item = state.collection.getItem(key);
if (item && item.type === 'item') {
Expand Down
30 changes: 30 additions & 0 deletions packages/@react-aria/select/test/HiddenSelect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,36 @@ describe('<HiddenSelect />', () => {
expect(onSelectionChange).toBeCalledWith('5');
});

it('should include a non-empty placeholder option for native select markup', () => {
render(
<HiddenSelectExample
label="select"
items={makeItems(5)} />
);

let select = screen.getByLabelText('select');
let firstOption = select.querySelector('option')!;

expect(firstOption).toHaveAttribute('value', '');
expect(firstOption).toHaveAttribute('label', '\u00A0');
});

it('should submit an empty string when no value is selected', () => {
let formRef = React.createRef<HTMLFormElement>();
render(
<form ref={formRef}>
<HiddenSelectExample
hiddenProps={{
name: 'select'
}}
items={makeItems(5)} />
</form>
);

let formData = new FormData(formRef.current!);
expect(formData.get('select')).toEqual('');
});

it('should always add a data attribute data-a11y-ignore="aria-hidden-focus"', () => {
render(
<HiddenSelectExample items={makeItems(5)} />
Expand Down
12 changes: 10 additions & 2 deletions packages/@react-aria/test-utils/src/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ interface SelectTriggerOptionOpts extends SelectOpenOpts {
/**
* The index, text, or node of the option to select. Option nodes can be sourced via `options()`.
*/
option: number | string | HTMLElement
option: number | string | HTMLElement,
/**
* Whether or not the select closes on selection. Depends on select implementation and configuration.
* @default true
*/
closesOnSelect?: boolean
}

export class SelectTester {
Expand Down Expand Up @@ -165,6 +170,7 @@ export class SelectTester {
async selectOption(opts: SelectTriggerOptionOpts): Promise<void> {
let {
option,
closesOnSelect,
interactionType = this._interactionType
} = opts || {};
let trigger = this.trigger;
Expand All @@ -186,6 +192,8 @@ export class SelectTester {
}

let isMultiSelect = listbox.getAttribute('aria-multiselectable') === 'true';
let isSingleSelect = !isMultiSelect;
closesOnSelect = closesOnSelect ?? isSingleSelect;

if (interactionType === 'keyboard') {
if (option?.getAttribute('aria-disabled') === 'true') {
Expand All @@ -206,7 +214,7 @@ export class SelectTester {
}
}

if (!isMultiSelect && option?.getAttribute('href') == null) {
if (closesOnSelect && option?.getAttribute('href') == null) {
await waitFor(() => {
if (document.activeElement !== this._trigger) {
throw new Error(`Expected the document.activeElement after selecting an option to be the select component trigger but got ${document.activeElement}`);
Expand Down
4 changes: 2 additions & 2 deletions packages/@react-aria/utils/src/animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,13 @@ function useAnimation(ref: RefObject<HTMLElement | null>, isActive: boolean, onE
}

let canceled = false;
Promise.all(animations.map(a => a.finished)).then(() => {
Promise.allSettled(animations.map(a => a.finished)).then(() => {
if (!canceled) {
flushSync(() => {
onEnd();
});
}
}).catch(() => {});
});

return () => {
canceled = true;
Expand Down
Loading
Loading