Skip to content

Commit a2678d3

Browse files
authored
RAC: Fix Group render props and focus-visible behavior with text input children (#5359)
Fixes: - Group now works with render props, no longer errors with "Warning: Functions are not valid as a React child." - Group's focus ring will render correctly with text input children.
1 parent 0b541f4 commit a2678d3

File tree

3 files changed

+164
-8
lines changed

3 files changed

+164
-8
lines changed

packages/@react-aria/interactions/src/useFocusVisible.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,11 +196,27 @@ export function useInteractionModality(): Modality | null {
196196
return useIsSSR() ? null : modality;
197197
}
198198

199+
const nonTextInputTypes = new Set([
200+
'checkbox',
201+
'radio',
202+
'range',
203+
'color',
204+
'file',
205+
'image',
206+
'button',
207+
'submit',
208+
'reset'
209+
]);
210+
199211
/**
200212
* If this is attached to text input component, return if the event is a focus event (Tab/Escape keys pressed) so that
201213
* focus visible style can be properly set.
202214
*/
203215
function isKeyboardFocusEvent(isTextInput: boolean, modality: Modality, e: HandlerEvent) {
216+
isTextInput = isTextInput ||
217+
(e?.target instanceof HTMLInputElement && !nonTextInputTypes.has(e?.target?.type)) ||
218+
e?.target instanceof HTMLTextAreaElement ||
219+
(e?.target instanceof HTMLElement && e?.target.isContentEditable);
204220
return !(isTextInput && modality === 'keyboard' && e instanceof KeyboardEvent && !FOCUS_VISIBLE_INPUT_KEYS[e.key]);
205221
}
206222

packages/react-aria-components/src/Group.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {AriaLabelingProps, HoverEvents} from '@react-types/shared';
14-
import {ContextValue, StyleRenderProps, useContextProps, useRenderProps} from './utils';
15-
import {mergeProps, useFocusRing, useHover} from 'react-aria';
13+
import {AriaLabelingProps, DOMProps} from '@react-types/shared';
14+
import {ContextValue, forwardRefType, RenderProps, SlotProps, useContextProps, useRenderProps} from './utils';
15+
import {HoverProps, mergeProps, useFocusRing, useHover} from 'react-aria';
1616
import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes} from 'react';
1717

1818
export interface GroupRenderProps {
@@ -43,7 +43,7 @@ export interface GroupRenderProps {
4343
isInvalid: boolean
4444
}
4545

46-
export interface GroupProps extends AriaLabelingProps, Omit<HTMLAttributes<HTMLElement>, 'className' | 'style' | 'role'>, HoverEvents, StyleRenderProps<GroupRenderProps> {
46+
export interface GroupProps extends AriaLabelingProps, Omit<HTMLAttributes<HTMLElement>, 'children' | 'className' | 'style' | 'role' | 'slot'>, DOMProps, HoverProps, RenderProps<GroupRenderProps>, SlotProps {
4747
/** Whether the group is disabled. */
4848
isDisabled?: boolean,
4949
/** Whether the group is invalid. */
@@ -62,13 +62,13 @@ export const GroupContext = createContext<ContextValue<GroupProps, HTMLDivElemen
6262

6363
function Group(props: GroupProps, ref: ForwardedRef<HTMLDivElement>) {
6464
[props, ref] = useContextProps(props, ref, GroupContext);
65+
let {isDisabled, isInvalid, onHoverStart, onHoverChange, onHoverEnd, ...otherProps} = props;
6566

66-
let {hoverProps, isHovered} = useHover(props);
67+
let {hoverProps, isHovered} = useHover({onHoverStart, onHoverChange, onHoverEnd, isDisabled});
6768
let {isFocused, isFocusVisible, focusProps} = useFocusRing({
6869
within: true
6970
});
7071

71-
let {isDisabled, isInvalid, ...otherProps} = props;
7272
isDisabled ??= !!props['aria-disabled'] && props['aria-disabled'] !== 'false';
7373
isInvalid ??= !!props['aria-invalid'] && props['aria-invalid'] !== 'false';
7474
let renderProps = useRenderProps({
@@ -83,18 +83,19 @@ function Group(props: GroupProps, ref: ForwardedRef<HTMLDivElement>) {
8383
{...renderProps}
8484
ref={ref}
8585
role={props.role ?? 'group'}
86+
slot={props.slot ?? undefined}
8687
data-focus-within={isFocused || undefined}
8788
data-hovered={isHovered || undefined}
8889
data-focus-visible={isFocusVisible || undefined}
8990
data-disabled={isDisabled || undefined}
9091
data-invalid={isInvalid || undefined}>
91-
{props.children}
92+
{renderProps.children}
9293
</div>
9394
);
9495
}
9596

9697
/**
9798
* A group represents a set of related UI controls, and supports interactive states for styling.
9899
*/
99-
const _Group = forwardRef(Group);
100+
const _Group = /*#__PURE__*/ (forwardRef as forwardRefType)(Group);
100101
export {_Group as Group};
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/*
2+
* Copyright 2023 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
import {Group, GroupContext} from '..';
13+
import {pointerMap, render} from '@react-spectrum/test-utils';
14+
import React from 'react';
15+
import userEvent from '@testing-library/user-event';
16+
17+
describe('Group', () => {
18+
let user;
19+
beforeAll(() => {
20+
user = userEvent.setup({delay: null, pointerMap});
21+
});
22+
23+
it('should render a group with default class', () => {
24+
let {getByRole} = render(<Group>Test</Group>);
25+
let group = getByRole('group');
26+
expect(group).toHaveAttribute('class', 'react-aria-Group');
27+
});
28+
29+
it('should render a group with custom class', () => {
30+
let {getByRole} = render(<Group className="test">Test</Group>);
31+
let group = getByRole('group');
32+
expect(group).toHaveAttribute('class', 'test');
33+
});
34+
35+
it('should support DOM props', () => {
36+
let {getByRole} = render(<Group data-foo="bar">Test</Group>);
37+
let group = getByRole('group');
38+
expect(group).toHaveAttribute('data-foo', 'bar');
39+
});
40+
41+
it('should support slot', () => {
42+
let {getByRole} = render(
43+
<GroupContext.Provider value={{slots: {test: {'aria-label': 'test'}}}}>
44+
<Group slot="test">Test</Group>
45+
</GroupContext.Provider>
46+
);
47+
48+
let group = getByRole('group');
49+
expect(group).toHaveAttribute('slot', 'test');
50+
expect(group).toHaveAttribute('aria-label', 'test');
51+
});
52+
53+
it('should support hover', async () => {
54+
let hoverStartSpy = jest.fn();
55+
let hoverChangeSpy = jest.fn();
56+
let hoverEndSpy = jest.fn();
57+
let {getByRole} = render(<Group
58+
className={({isHovered}) => isHovered ? 'hover' : ''}
59+
onHoverStart={hoverStartSpy}
60+
onHoverChange={hoverChangeSpy}
61+
onHoverEnd={hoverEndSpy}>Test</Group>);
62+
let group = getByRole('group');
63+
64+
expect(group).not.toHaveAttribute('data-hovered');
65+
expect(group).not.toHaveClass('hover');
66+
67+
await user.hover(group);
68+
expect(group).toHaveAttribute('data-hovered', 'true');
69+
expect(group).toHaveClass('hover');
70+
expect(hoverStartSpy).toHaveBeenCalledTimes(1);
71+
expect(hoverChangeSpy).toHaveBeenCalledTimes(1);
72+
73+
await user.unhover(group);
74+
expect(group).not.toHaveAttribute('data-hovered');
75+
expect(group).not.toHaveClass('hover');
76+
expect(hoverEndSpy).toHaveBeenCalledTimes(1);
77+
expect(hoverChangeSpy).toHaveBeenCalledTimes(2);
78+
});
79+
80+
it('should support focus ring', async () => {
81+
let {getByRole} = render(<Group className={({isFocusVisible}) => isFocusVisible ? 'focus' : ''}>
82+
<input type="text" />
83+
</Group>);
84+
let group = getByRole('group');
85+
let input = getByRole('textbox');
86+
87+
expect(group).not.toHaveAttribute('data-focus-visible');
88+
expect(group).not.toHaveClass('focus');
89+
90+
await user.tab();
91+
expect(document.activeElement).toBe(input);
92+
expect(group).toHaveAttribute('data-focus-visible', 'true');
93+
expect(group).toHaveClass('focus');
94+
95+
await user.tab();
96+
expect(group).not.toHaveAttribute('data-focus-visible');
97+
expect(group).not.toHaveClass('focus');
98+
});
99+
100+
it('should not show focus ring when typing in pointer modality', async () => {
101+
let {getByRole} = render(<Group className={({isFocusVisible}) => isFocusVisible ? 'focus' : ''}>
102+
<input type="text" />
103+
</Group>);
104+
let group = getByRole('group');
105+
let input = getByRole('textbox');
106+
107+
expect(group).not.toHaveAttribute('data-focus-visible');
108+
expect(group).not.toHaveClass('focus');
109+
110+
await user.click(input);
111+
await user.keyboard('a');
112+
expect(document.activeElement).toBe(input);
113+
expect(input).toHaveValue('a');
114+
115+
expect(group).not.toHaveAttribute('data-focus-visible');
116+
expect(group).not.toHaveClass('focus');
117+
});
118+
119+
it('should support disabled state', () => {
120+
let {getByRole} = render(<Group isDisabled className={({isDisabled}) => isDisabled ? 'disabled' : ''}>Test</Group>);
121+
let group = getByRole('group');
122+
123+
expect(group).toHaveAttribute('data-disabled', 'true');
124+
expect(group).toHaveClass('disabled');
125+
});
126+
127+
it('should support render props', async () => {
128+
let {getByRole} = render(<Group>{({isHovered}) => isHovered ? 'Hovered' : 'Group'}</Group>);
129+
let group = getByRole('group');
130+
131+
expect(group).toHaveTextContent('Group');
132+
133+
await user.hover(group);
134+
expect(group).toHaveTextContent('Hovered');
135+
136+
await user.unhover(group);
137+
expect(group).toHaveTextContent('Group');
138+
});
139+
});

0 commit comments

Comments
 (0)