Skip to content

feat: Support tooltips on Tags and Tabs #7793

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 20, 2025
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
1 change: 1 addition & 0 deletions packages/@react-aria/collections/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"url": "https://github.com/adobe/react-spectrum"
},
"dependencies": {
"@react-aria/interactions": "^3.23.0",
"@react-aria/ssr": "^3.9.7",
"@react-aria/utils": "^3.27.0",
"@react-types/shared": "^3.27.0",
Expand Down
16 changes: 15 additions & 1 deletion packages/@react-aria/collections/src/CollectionBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {BaseCollection} from './BaseCollection';
import {BaseNode, Document, ElementNode} from './Document';
import {CachedChildrenOptions, useCachedChildren} from './useCachedChildren';
import {createPortal} from 'react-dom';
import {FocusableContext} from '@react-aria/interactions';
import {forwardRefType, Node} from '@react-types/shared';
import {Hidden} from './Hidden';
import React, {createContext, ForwardedRef, forwardRef, JSX, ReactElement, ReactNode, useCallback, useContext, useMemo, useRef, useState} from 'react';
Expand Down Expand Up @@ -161,6 +162,7 @@ export function createLeafComponent<T extends object, P extends object, E extend
export function createLeafComponent<P extends object, E extends Element>(type: string, render: (props: P, ref: ForwardedRef<E>, node?: any) => ReactElement) {
let Component = ({node}) => render(node.props, node.props.ref, node);
let Result = (forwardRef as forwardRefType)((props: P, ref: ForwardedRef<E>) => {
let focusableProps = useContext(FocusableContext);
let isShallow = useContext(ShallowRenderContext);
if (!isShallow) {
if (render.length >= 3) {
Expand All @@ -169,7 +171,19 @@ export function createLeafComponent<P extends object, E extends Element>(type: s
return render(props, ref);
}

return useSSRCollectionNode(type, props, ref, 'children' in props ? props.children : null, null, node => <Component node={node} />);
return useSSRCollectionNode(
type,
props,
ref,
'children' in props ? props.children : null,
null,
node => (
// Forward FocusableContext to real DOM tree so tooltips work.
<FocusableContext.Provider value={focusableProps}>
<Component node={node} />
</FocusableContext.Provider>
)
);
});
// @ts-ignore
Result.displayName = render.name;
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/interactions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export {useMove} from './useMove';
export {usePress} from './usePress';
export {useScrollWheel} from './useScrollWheel';
export {useLongPress} from './useLongPress';
export {useFocusable, FocusableProvider, Focusable} from './useFocusable';
export {useFocusable, FocusableProvider, Focusable, FocusableContext} from './useFocusable';
export {focusSafely} from './focusSafely';

export type {FocusProps, FocusResult} from './useFocus';
Expand Down
4 changes: 3 additions & 1 deletion packages/@react-aria/interactions/src/useFocusable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ interface FocusableContextValue extends FocusableProviderProps {
ref?: MutableRefObject<FocusableElement | null>
}

let FocusableContext = React.createContext<FocusableContextValue | null>(null);
// Exported for @react-aria/collections, which forwards this context.
/** @private */
export let FocusableContext = React.createContext<FocusableContextValue | null>(null);

function useFocusableContext(ref: RefObject<FocusableElement | null>): FocusableContextValue {
let context = useContext(FocusableContext) || {};
Expand Down
6 changes: 5 additions & 1 deletion packages/@react-aria/tabs/src/useTab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {DOMAttributes, FocusableElement, RefObject} from '@react-types/shared';
import {filterDOMProps, mergeProps, useLinkProps} from '@react-aria/utils';
import {generateId} from './utils';
import {TabListState} from '@react-stately/tabs';
import {useFocusable} from '@react-aria/focus';
import {useSelectableItem} from '@react-aria/selection';

export interface TabAria {
Expand Down Expand Up @@ -60,9 +61,12 @@ export function useTab<T>(
let domProps = filterDOMProps(item?.props, {labelable: true});
delete domProps.id;
let linkProps = useLinkProps(item?.props);
let {focusableProps} = useFocusable({
isDisabled
}, ref);

return {
tabProps: mergeProps(domProps, linkProps, itemProps, {
tabProps: mergeProps(domProps, focusableProps, linkProps, itemProps, {
id: tabId,
'aria-selected': isSelected,
'aria-disabled': isDisabled || undefined,
Expand Down
8 changes: 6 additions & 2 deletions packages/@react-aria/tag/src/useTag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import intlMessages from '../intl/*.json';
import {KeyboardEvent} from 'react';
import type {ListState} from '@react-stately/list';
import {SelectableItemStates} from '@react-aria/selection';
import {useFocusable, useInteractionModality} from '@react-aria/interactions';
import {useGridListItem} from '@react-aria/gridlist';
import {useInteractionModality} from '@react-aria/interactions';
import {useLocalizedStringFormatter} from '@react-aria/i18n';


Expand Down Expand Up @@ -93,6 +93,10 @@ export function useTag<T>(props: AriaTagProps<T>, state: ListState<T>, ref: RefO

let domProps = filterDOMProps(item.props);
let linkProps = useSyntheticLinkProps(item.props);
let {focusableProps} = useFocusable({
isDisabled
}, ref);

return {
removeButtonProps: {
'aria-label': stringFormatter.format('removeButtonLabel'),
Expand All @@ -102,7 +106,7 @@ export function useTag<T>(props: AriaTagProps<T>, state: ListState<T>, ref: RefO
onPress: () => onRemove ? onRemove(new Set([item.key])) : null,
excludeFromTabOrder: true
},
rowProps: mergeProps(rowProps, domProps, linkProps, {
rowProps: mergeProps(focusableProps, rowProps, domProps, linkProps, {
tabIndex,
onKeyDown: onRemove ? onKeyDown : undefined,
'aria-describedby': descProps['aria-describedby']
Expand Down
22 changes: 20 additions & 2 deletions packages/react-aria-components/stories/Tabs.stories.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 {Button, Tab, TabList, TabPanel, TabProps, Tabs, TabsProps} from 'react-aria-components';
import {Button, OverlayArrow, Tab, TabList, TabPanel, TabProps, Tabs, TabsProps, Tooltip, TooltipTrigger} from 'react-aria-components';
import React, {useState} from 'react';
import {RouterProvider} from '@react-aria/utils';

Expand All @@ -27,7 +27,25 @@ export const TabsExample = () => {
<TabList aria-label="History of Ancient Rome" style={{display: 'flex', gap: 8}}>
<CustomTab id="/FoR" href="/FoR">Founding of Rome</CustomTab>
<CustomTab id="/MaR" href="/MaR">Monarchy and Republic</CustomTab>
<CustomTab id="/Emp" href="/Emp">Empire</CustomTab>
<TooltipTrigger>
<CustomTab id="/Emp" href="/Emp">Empire</CustomTab>
<Tooltip
offset={5}
style={{
background: 'Canvas',
color: 'CanvasText',
border: '1px solid gray',
padding: 5,
borderRadius: 4
}}>
<OverlayArrow style={{transform: 'translateX(-50%)'}}>
<svg width="8" height="8" style={{display: 'block'}}>
<path d="M0 0L4 4L8 0" fill="white" strokeWidth={1} stroke="gray" />
</svg>
</OverlayArrow>
I am a tooltip
</Tooltip>
</TooltipTrigger>
</TabList>
<TabPanel id="/FoR">
Arma virumque cano, Troiae qui primus ab oris.
Expand Down
22 changes: 20 additions & 2 deletions packages/react-aria-components/stories/TagGroup.stories.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 {Label, Tag, TagGroup, TagGroupProps, TagList, TagProps} from 'react-aria-components';
import {Label, OverlayArrow, Tag, TagGroup, TagGroupProps, TagList, TagProps, Tooltip, TooltipTrigger} from 'react-aria-components';
import React from 'react';

export default {
Expand All @@ -24,7 +24,25 @@ export const TagGroupExample = (props: TagGroupProps) => (
<MyTag href="https://nytimes.com">News</MyTag>
<MyTag>Travel</MyTag>
<MyTag>Gaming</MyTag>
<MyTag>Shopping</MyTag>
<TooltipTrigger>
<MyTag>Shopping</MyTag>
<Tooltip
offset={5}
style={{
background: 'Canvas',
color: 'CanvasText',
border: '1px solid gray',
padding: 5,
borderRadius: 4
}}>
<OverlayArrow style={{transform: 'translateX(-50%)'}}>
<svg width="8" height="8" style={{display: 'block'}}>
<path d="M0 0L4 4L8 0" fill="white" strokeWidth={1} stroke="gray" />
</svg>
</OverlayArrow>
I am a tooltip
</Tooltip>
</TooltipTrigger>
</TagList>
</TagGroup>
);
Expand Down
29 changes: 27 additions & 2 deletions packages/react-aria-components/test/Tabs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
* governing permissions and limitations under the License.
*/

import {Button, Collection, Tab, TabList, TabPanel, Tabs} from '../';
import {fireEvent, pointerMap, render, waitFor, within} from '@react-spectrum/test-utils-internal';
import {act, fireEvent, pointerMap, render, waitFor, within} from '@react-spectrum/test-utils-internal';
import {Button, Collection, Tab, TabList, TabPanel, Tabs, Tooltip, TooltipTrigger} from '../';
import React, {useState} from 'react';
import {TabsExample} from '../stories/Tabs.stories';
import {User} from '@react-aria/test-utils';
Expand All @@ -36,6 +36,7 @@ describe('Tabs', () => {

beforeAll(() => {
user = userEvent.setup({delay: null, pointerMap});
jest.useFakeTimers();
});

it('should render tabs with default classes', () => {
Expand Down Expand Up @@ -595,4 +596,28 @@ describe('Tabs', () => {
tabs = getAllByRole('tab');
expect(tabs[2]).toHaveAttribute('aria-selected', 'true');
});

it('supports tooltips', async function () {
let {getByRole, getAllByRole} = render(
<Tabs>
<TabList aria-label="Test">
<Tab id="a">A</Tab>
<Tab id="b">B</Tab>
<TooltipTrigger>
<Tab id="c">C</Tab>
<Tooltip>Test</Tooltip>
</TooltipTrigger>
</TabList>
<TabPanel id="a">A</TabPanel>
<TabPanel id="b">B</TabPanel>
<TabPanel id="c">C</TabPanel>
</Tabs>
);

let tab = getAllByRole('tab')[2];
fireEvent.mouseMove(document.body);
await user.hover(tab);
act(() => jest.runAllTimers());
expect(getByRole('tooltip')).toHaveTextContent('Test');
});
});
23 changes: 22 additions & 1 deletion packages/react-aria-components/test/TagGroup.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

import {act, fireEvent, mockClickDefault, pointerMap, render} from '@react-spectrum/test-utils-internal';
import {Button, Label, RouterProvider, Tag, TagGroup, TagList, Text} from '../';
import {Button, Label, RouterProvider, Tag, TagGroup, TagList, Text, Tooltip, TooltipTrigger} from '../';
import React from 'react';
import {useListData} from '@react-stately/data';
import userEvent from '@testing-library/user-event';
Expand Down Expand Up @@ -306,6 +306,27 @@ describe('TagGroup', () => {
expect(grid).toHaveTextContent('No results');
});

it('supports tooltips', async function () {
let {getByRole, getAllByRole} = render(
<TagGroup>
<Label>Test</Label>
<TagList>
<RemovableTag id="cat">Cat</RemovableTag>
<RemovableTag id="dog">Dog</RemovableTag>
<TooltipTrigger>
<RemovableTag id="kangaroo">Kangaroo</RemovableTag>
<Tooltip>Test</Tooltip>
</TooltipTrigger>
</TagList>
</TagGroup>
);

let tag = getAllByRole('row')[2];
await user.hover(tag);
act(() => jest.runAllTimers());
expect(getByRole('tooltip')).toHaveTextContent('Test');
});

describe('supports links', function () {
describe.each(['mouse', 'keyboard'])('%s', (type) => {
let trigger = async item => {
Expand Down
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6008,6 +6008,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@react-aria/collections@workspace:packages/@react-aria/collections"
dependencies:
"@react-aria/interactions": "npm:^3.23.0"
"@react-aria/ssr": "npm:^3.9.7"
"@react-aria/utils": "npm:^3.27.0"
"@react-types/shared": "npm:^3.27.0"
Expand Down