Skip to content
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

feat: Tooltips in collection components #7289

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
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
10 changes: 9 additions & 1 deletion packages/@react-aria/collections/src/CollectionBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export function CollectionBuilder<C extends BaseCollection<object>>(props: Colle
);
}


function CollectionInner({collection, render}) {
return render(collection);
}
Expand Down Expand Up @@ -116,7 +117,7 @@ function useCollectionDocument<T extends object, C extends BaseCollection<T>>(cr
useLayoutEffect(() => {
document.isMounted = true;
return () => {
// Mark unmounted so we can skip all of the collection updates caused by
// Mark unmounted so we can skip all of the collection updates caused by
// React calling removeChild on every item in the collection.
document.isMounted = false;
};
Expand Down Expand Up @@ -176,9 +177,16 @@ export function createLeafComponent<P extends object, E extends Element>(type: s
return Result;
}

// TODO fix the override types
Copy link
Member Author

@snowystinger snowystinger Jan 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

something about these overrides isn't quite right

export function createBranchComponent<T extends object, P extends {children?: any}, E extends Element>(type: string, render: (props: P, ref: ForwardedRef<E>) => ReactElement, useChildren?: (props: P) => ReactNode): (props: P & React.RefAttributes<T>) => ReactElement | null;
export function createBranchComponent<T extends object, P extends {children?: any}, E extends Element>(type: string, render: (props: P, ref: ForwardedRef<E>, node: Node<T>) => ReactElement, useChildren?: (props: P) => ReactNode): (props: P & React.RefAttributes<T>) => ReactElement | null;
export function createBranchComponent<T extends object, P extends object, E extends Element>(type: string, render: (props: P, ref: ForwardedRef<E>, node?: any) => ReactElement, useChildren: (props: P) => ReactNode = useCollectionChildren) {

They complain in TableView and some others that the Element type on the forwardedRef doesn't match (true, but I haven't figured out how to get the generic to work)

export function createBranchComponent<T extends object, P extends {children?: any}, E extends Element>(type: string, render: (props: P, ref: ForwardedRef<E>, node: Node<T>) => ReactElement, useChildren: (props: P) => ReactNode = useCollectionChildren) {
let Component = ({node}) => render(node.props, node.props.ref, node);
let Result = (forwardRef as forwardRefType)((props: P, ref: ForwardedRef<E>) => {
let isShallow = useContext(ShallowRenderContext);
if (!isShallow) {
// @ts-ignore will be fixed with overrides once i figure out how
return render(props, ref);
}

let children = useChildren(props);
return useSSRCollectionNode(type, props, ref, null, children, node => <Component node={node} />) ?? <></>;
});
Expand Down
75 changes: 59 additions & 16 deletions packages/@react-aria/tabs/src/TabsKeyboardDelegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,60 +18,103 @@ export class TabsKeyboardDelegate<T> implements KeyboardDelegate {
private disabledKeys: Set<Key>;
private tabDirection: boolean;

protected isTab(node: Node<T>) {
return node.type === 'item';
}

constructor(collection: Collection<Node<T>>, direction: Direction, orientation: Orientation, disabledKeys: Set<Key> = new Set()) {
this.collection = collection;
this.flipDirection = direction === 'rtl' && orientation === 'horizontal';
this.disabledKeys = disabledKeys;
this.tabDirection = orientation === 'horizontal';
}

protected findPreviousKey(fromKey?: Key, pred?: (item: Node<T>) => boolean) {
let key = fromKey != null
? this.collection.getKeyBefore(fromKey)
: this.collection.getLastKey();

while (key != null) {
let item = this.collection.getItem(key)!;
if (!this.isDisabled(item) && (!pred || pred(item))) {
return key;
}

key = this.collection.getKeyBefore(key);
}
key = this.findPreviousKey(undefined, pred);
return key;
}

protected findNextKey(fromKey?: Key, pred?: (item: Node<T>) => boolean) {
let key = fromKey != null
? this.collection.getKeyAfter(fromKey)
: this.collection.getFirstKey();

while (key != null) {
let item = this.collection.getItem(key)!;
if (!this.isDisabled(item) && (!pred || pred(item))) {
return key;
}

key = this.collection.getKeyAfter(key);
}
key = this.findNextKey(undefined, pred);
return key;
}

private isDisabled(item: Node<unknown>) {
return (item.props?.isDisabled || this.disabledKeys.has(item.key));
}

getKeyLeftOf(key: Key) {
if (this.flipDirection) {
return this.getNextKey(key);
return this.findNextKey(key, (item => item.type === 'item'));
}
return this.getPreviousKey(key);
return this.findPreviousKey(key, (item => item.type === 'item'));
}

getKeyRightOf(key: Key) {
if (this.flipDirection) {
return this.getPreviousKey(key);
return this.findPreviousKey(key, (item => item.type === 'item'));
}
return this.getNextKey(key);
}


private isDisabled(key: Key) {
return this.disabledKeys.has(key) || !!this.collection.getItem(key)?.props?.isDisabled;
return this.findNextKey(key, (item => item.type === 'item'));
}

getFirstKey() {
let key = this.collection.getFirstKey();
if (key != null && this.isDisabled(key)) {
key = this.getNextKey(key);
if (key != null) {
let item = this.collection.getItem(key)!;
if (this.isDisabled(item) || item.type !== 'item') {
key = this.findNextKey(key);
}
}
return key;
}

getLastKey() {
let key = this.collection.getLastKey();
if (key != null && this.isDisabled(key)) {
key = this.getPreviousKey(key);
if (key != null) {
let item = this.collection.getItem(key)!;
if (this.isDisabled(item) || item.type !== 'item') {
key = this.findPreviousKey(key);
}
}
return key;
}

getKeyAbove(key: Key) {
if (this.tabDirection) {
return null;
}
return this.getPreviousKey(key);
return this.findPreviousKey(key, (item => item.type === 'item'));
}

getKeyBelow(key: Key) {
if (this.tabDirection) {
return null;
}
return this.getNextKey(key);
return this.findNextKey(key, (item => item.type === 'item'));
}

getNextKey(key) {
Expand Down
3 changes: 2 additions & 1 deletion packages/@react-spectrum/s2/src/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,8 @@ let CollapsingCollectionRenderer: CollectionRenderer = {
return useCollectionRender(collection);
},
CollectionBranch({collection}) {
return useCollectionRender(collection);
// TODO breaking change in CollectionRenderer making this optional?
return useCollectionRender(collection!);
}
};

Expand Down
19 changes: 15 additions & 4 deletions packages/react-aria-components/src/Collection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import {CollectionBase, DropTargetDelegate, ItemDropTarget, Key, LayoutDelegate, RefObject} from '@react-types/shared';
import {Collection, CollectionBase, DropTargetDelegate, ItemDropTarget, Key, LayoutDelegate, RefObject} from '@react-types/shared';
import {createBranchComponent, useCachedChildren} from '@react-aria/collections';
import {Collection as ICollection, Node, SelectionBehavior, SelectionMode, SectionProps as SharedSectionProps} from 'react-stately';
import React, {createContext, ForwardedRef, HTMLAttributes, JSX, ReactElement, ReactNode, useContext, useMemo} from 'react';
Expand Down Expand Up @@ -108,7 +108,7 @@ export const Section = /*#__PURE__*/ createBranchComponent('section', <T extends

export interface CollectionBranchProps {
/** The collection of items to render. */
collection: ICollection<Node<unknown>>,
collection?: ICollection<Node<unknown>>,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

opinions on this change? It's kind of breaking as noted in Breadcrumbs

/** The parent node of the items to render. */
parent: Node<unknown>,
/** A function that renders a drop indicator between items. */
Expand Down Expand Up @@ -139,12 +139,23 @@ export interface CollectionRenderer {
CollectionBranch: React.ComponentType<CollectionBranchProps>
}

let CollectionStateContext = createContext<Collection<Node<unknown>> | undefined | null>(null);

export const DefaultCollectionRenderer: CollectionRenderer = {
CollectionRoot({collection, renderDropIndicator}) {
return useCollectionRender(collection, null, renderDropIndicator);
let result = useCollectionRender(collection, null, renderDropIndicator);
return <CollectionStateContext.Provider value={collection}>{result}</CollectionStateContext.Provider>;
},
CollectionBranch({collection, parent, renderDropIndicator}) {
return useCollectionRender(collection, parent, renderDropIndicator);
let thisCollection: Collection<Node<unknown>> | undefined | null = collection;
let parentCollection = useContext(CollectionStateContext);
if (thisCollection == null) {
thisCollection = parentCollection;
}
if (!thisCollection) {
throw new Error('Collection not found in context.');
}
return useCollectionRender(thisCollection, parent, renderDropIndicator);
}
};

Expand Down
4 changes: 3 additions & 1 deletion packages/react-aria-components/src/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {ContextValue, Provider, RenderProps, SlotProps, StyleRenderProps, useCon
import {filterDOMProps, inertValue, useObjectRef} from '@react-aria/utils';
import {Collection as ICollection, Node, TabListState, useTabListState} from 'react-stately';
import React, {createContext, ForwardedRef, forwardRef, JSX, useContext, useMemo} from 'react';
import {useFocusable} from '@react-aria/focus';

export interface TabsProps extends Omit<AriaTabListProps<any>, 'items' | 'children'>, RenderProps<TabsRenderProps>, SlotProps {}

Expand Down Expand Up @@ -245,6 +246,7 @@ export const Tab = /*#__PURE__*/ createLeafComponent('item', (props: TabProps, f
let ref = useObjectRef<any>(forwardedRef);
let {tabProps, isSelected, isDisabled, isPressed} = useTab({key: item.key, ...props}, state, ref);
let {focusProps, isFocused, isFocusVisible} = useFocusRing();
let {focusableProps} = useFocusable({isDisabled: props.isDisabled}, ref);
let {hoverProps, isHovered} = useHover({
isDisabled,
onHoverStart: props.onHoverStart,
Expand All @@ -270,7 +272,7 @@ export const Tab = /*#__PURE__*/ createLeafComponent('item', (props: TabProps, f

return (
<ElementType
{...mergeProps(tabProps, focusProps, hoverProps, renderProps)}
{...mergeProps(tabProps, focusableProps, focusProps, hoverProps, renderProps)}
ref={ref}
data-selected={isSelected || undefined}
data-disabled={isDisabled || undefined}
Expand Down
42 changes: 35 additions & 7 deletions packages/react-aria-components/src/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@

import {AriaLabelingProps, FocusableElement, forwardRefType, RefObject} from '@react-types/shared';
import {AriaPositionProps, mergeProps, OverlayContainer, Placement, PlacementAxis, PositionProps, useOverlayPosition, useTooltip, useTooltipTrigger} from 'react-aria';
import {CollectionRendererContext} from './Collection';
import {ContextValue, Provider, RenderProps, useContextProps, useRenderProps} from './utils';
import {createBranchComponent} from '@react-aria/collections';
import {FocusableProvider} from '@react-aria/focus';
import {OverlayArrowContext} from './OverlayArrow';
import {OverlayTriggerProps, TooltipTriggerProps, TooltipTriggerState, useTooltipTriggerState} from 'react-stately';
import React, {createContext, ForwardedRef, forwardRef, ReactNode, useContext, useRef, useState} from 'react';
import {useEnterAnimation, useExitAnimation, useLayoutEffect} from '@react-aria/utils';
import {useEnterAnimation, useExitAnimation, useLayoutEffect, useObjectRef} from '@react-aria/utils';

export interface TooltipTriggerComponentProps extends TooltipTriggerProps {
children: ReactNode
Expand Down Expand Up @@ -80,23 +82,49 @@ export const TooltipContext = createContext<ContextValue<TooltipProps, HTMLDivEl
* the Tooltip when the user hovers over or focuses the trigger, and positioning the Tooltip
* relative to the trigger.
*/
export function TooltipTrigger(props: TooltipTriggerComponentProps) {
export const TooltipTrigger = /*#__PURE__*/createBranchComponent('tooltiptrigger', (props: TooltipTriggerComponentProps, ref: ForwardedRef<FocusableElement>, item) => {
let {CollectionBranch} = useContext(CollectionRendererContext);
let state = useTooltipTriggerState(props);
let ref = useRef<FocusableElement>(null);
let {triggerProps, tooltipProps} = useTooltipTrigger(props, state, ref);
let triggerRef = useObjectRef(ref);
let {triggerProps, tooltipProps} = useTooltipTrigger(props, state, triggerRef);

if (!item) { // we're not in a collection
return (
<Provider
values={[
[TooltipTriggerStateContext, state],
[TooltipContext, {...tooltipProps, triggerRef}]
]}>
<FocusableProvider {...triggerProps} ref={triggerRef}>
{props.children}
</FocusableProvider>
</Provider>
);
}

// TODO: what should we do about TooltipTriggers with a single child that wraps the trigger and the tooltip?
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comment says it

if (props.children == null || (Array.isArray(props.children) && props.children.length !== 2)) {
throw new Error('TooltipTrigger should have exactly two children: the trigger and the tooltip.');
}
return (
<Provider
values={[
[TooltipTriggerStateContext, state],
[TooltipContext, {...tooltipProps, triggerRef: ref}]
[TooltipContext, {...tooltipProps, triggerRef}]
]}>
<FocusableProvider {...triggerProps} ref={ref}>
{props.children}
<FocusableProvider {...triggerProps} ref={triggerRef}>
<CollectionBranch parent={item} />
{props.children[1]}
</FocusableProvider>
</Provider>
);
}, props => {
if (props.children == null || (Array.isArray(props.children) && props.children.length !== 2)) {
throw new Error('TooltipTrigger should have exactly two children: the trigger and the tooltip.');
}
return props.children[0];
}
);

/**
* A tooltip displays a description of an element on hover or focus.
Expand Down
24 changes: 21 additions & 3 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 Expand Up @@ -61,7 +79,7 @@ export const TabsRenderProps = () => {
style={{display: 'flex', flexDirection: orientation === 'vertical' ? 'column' : 'row', gap: 8}}>
<CustomTab id="FoR">Founding of Rome</CustomTab>
<CustomTab id="MaR">Monarchy and Republic</CustomTab>
<CustomTab id="Emp">Empire</CustomTab>
<TooltipTrigger><CustomTab id="Emp">Empire</CustomTab><Tooltip>This is a tooltip</Tooltip></TooltipTrigger>
</TabList>
<TabPanel id="FoR">
Arma virumque cano, Troiae qui primus ab oris.
Expand Down
Loading