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
5 changes: 5 additions & 0 deletions .nx/version-plans/version-plan-1782928816938.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
gamut: patch
---

enable dismissal of ToolTip via onClick for interactive components like IconButton, MenuItem (icon-only), and FloatingToolTip + InlineToolTip
2 changes: 1 addition & 1 deletion packages/gamut/src/Button/IconButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const IconButton = forwardRef<ButtonBaseElements, IconButtonProps>(
const iconSize = iconSizeMapping[buttonSize];

return (
<ToolTip info={tip} {...(tipProps as any)}>
<ToolTip closeOnClick info={tip} {...(tipProps as any)}>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

very similar to still including info as a prop, where it gets overridden if tipProps includes info --
where if tipProps doesn't include closeOnClick, then closeOnClick becomes the default (the ToolTip disappears after the target is clicked)

<IconButtonBase
{...props}
aria-label={ariaLabel || tip}
Expand Down
30 changes: 30 additions & 0 deletions packages/gamut/src/Button/__tests__/IconButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,34 @@ describe('IconButton', () => {

await waitFor(() => expect(view.queryAllByText(tip).length).toBe(2));
});

describe('closeOnClick', () => {
beforeEach(() => {
onClick.mockClear();
});

it('does not prevent button onClick when the inline tip click handler fires', async () => {
const { view } = renderView();

await userEvent.click(view.getByRole('button', { name: label }));

expect(onClick).toHaveBeenCalledTimes(1);
});

it('does not prevent button onClick when the floating tip click handler fires', async () => {
const { view } = renderFloatingView();

await userEvent.click(view.getByRole('button', { name: label }));

expect(onClick).toHaveBeenCalledTimes(1);
});
Comment on lines +87 to +93

it('does not prevent button onClick when closeOnClick is disabled', async () => {
const { view } = renderView({ tipProps: { closeOnClick: false } });

await userEvent.click(view.getByRole('button', { name: label }));

expect(onClick).toHaveBeenCalledTimes(1);
});
});
});
9 changes: 8 additions & 1 deletion packages/gamut/src/Menu/MenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,15 @@ interface MenuItemIconOnly extends HTMLProps, ForwardListItemProps {
/** ToolTips will only render for interactive items, otherwise the label will be used as a generic aria-label */
label: ToolTipLabel;
disabled?: boolean;
closeOnClick?: boolean;
}

interface MenuTextItem extends HTMLProps, ForwardListItemProps {
icon?: React.ComponentType<GamutIconProps>;
children: React.ReactNode;
label?: ToolTipLabel;
disabled?: boolean;
closeOnClick?: never;
}

type MenuItemTypes = MenuItemIconOnly | MenuTextItem;
Expand All @@ -76,6 +78,7 @@ export const MenuItem = forwardRef<
target,
width = 1,
'aria-label': explicitAriaLabel,
closeOnClick = true,
...props
},
ref
Expand Down Expand Up @@ -160,7 +163,11 @@ export const MenuItem = forwardRef<

return (
<ListItem {...listItemProps}>
<MenuToolTipWrapper label={label} tipId={tipId}>
<MenuToolTipWrapper
closeOnClick={closeOnClick}
label={label}
tipId={tipId}
>
<ListButton
{...(computed as ListLinkProps)}
ref={buttonRef}
Expand Down
9 changes: 7 additions & 2 deletions packages/gamut/src/Menu/elements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,9 @@ export const ListButton = styled(
export const MenuToolTipWrapper: React.FC<
Pick<ComponentProps<typeof MenuItem>, 'children' | 'label'> & {
tipId: string;
closeOnClick?: boolean;
}
> = ({ children, label, tipId }) => {
> = ({ children, label, tipId, closeOnClick }) => {
if (!label) {
return <>{children}</>;
}
Expand All @@ -267,5 +268,9 @@ export const MenuToolTipWrapper: React.FC<
...defaultTipProps,
};

return <ToolTip {...(wrapperProps as ToolTipProps)}>{children}</ToolTip>;
return (
<ToolTip closeOnClick={closeOnClick} {...(wrapperProps as ToolTipProps)}>
{children}
</ToolTip>
);
};
7 changes: 7 additions & 0 deletions packages/gamut/src/Tip/ToolTip/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import {
export type ToolTipProps = TipBaseProps &
WithChildrenProp & {
alignment?: TipCenterAlignment;
/**
* If true, the tooltip closes immediately when the trigger is clicked or activated via keyboard.
* Pass `false` via `tipProps` on IconButton to opt out (e.g. copy → copied patterns).
*/
closeOnClick?: boolean;
Comment on lines +16 to +20
/**
* Can be used for accessibility - the same id needs to be passed to the `aria-describedby` attribute of the element that the tooltip is describing.
*/
Expand All @@ -21,6 +26,7 @@ export type ToolTipProps = TipBaseProps &

export const ToolTip: React.FC<ToolTipProps> = ({
alignment = 'top-center',
closeOnClick = true,
children,
info,
placement = tipDefaultProps.placement,
Expand All @@ -39,6 +45,7 @@ export const ToolTip: React.FC<ToolTipProps> = ({

const tipProps = {
alignment,
closeOnClick,
info,
wrapperRef,
...rest,
Expand Down
17 changes: 17 additions & 0 deletions packages/gamut/src/Tip/shared/FloatingTip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const FloatingTip: React.FC<TipWrapperProps> = ({
alignment,
avatar,
children,
closeOnClick,
escapeKeyPressHandler,
inheritDims,
info,
Expand Down Expand Up @@ -122,6 +123,21 @@ export const FloatingTip: React.FC<TipWrapperProps> = ({
? (e: FocusOrMouseEvent) => handleShowHideAction(e)
: undefined;

const handleClick = useCallback(() => {
if (hoverDelayRef.current) {
clearTimeout(hoverDelayRef.current);
hoverDelayRef.current = undefined;
}
if (focusDelayRef.current) {
clearTimeout(focusDelayRef.current);
focusDelayRef.current = undefined;
}
setIsOpen(false);
setIsFocused(false);
}, []);

const clickHandler = closeOnClick && isHoverType ? handleClick : undefined;

const contents = isPreviewType ? (
<PreviewTipContents
avatar={avatar}
Expand Down Expand Up @@ -151,6 +167,7 @@ export const FloatingTip: React.FC<TipWrapperProps> = ({
ref={ref}
width={inheritDims ? 'inherit' : undefined}
onBlur={toolOnlyEventFunc}
onClick={clickHandler}
onFocus={toolOnlyEventFunc}
onKeyDown={escapeKeyPressHandler}
onMouseDown={(e) => e.preventDefault()}
Expand Down
36 changes: 33 additions & 3 deletions packages/gamut/src/Tip/shared/InlineTip.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useCallback, useState } from 'react';

Comment on lines +1 to +2
import { InfoTipContainer } from '../InfoTip/styles';
import { PreviewTipContents, PreviewTipShadow } from '../PreviewTip/elements';
import { ToolTipContainer } from '../ToolTip/elements';
Expand All @@ -15,6 +17,7 @@ export const InlineTip: React.FC<TipWrapperProps> = ({
alignment,
avatar,
children,
closeOnClick,
escapeKeyPressHandler,
id,
inheritDims,
Expand All @@ -31,13 +34,34 @@ export const InlineTip: React.FC<TipWrapperProps> = ({
zIndex,
}) => {
const isHoverType = type === 'tool' || type === 'preview';
const [isDismissed, setIsDismissed] = useState(false);

const handleClick = useCallback(() => {
if (closeOnClick) setIsDismissed(true);
}, [closeOnClick]);

const handleUndismissed = useCallback(() => setIsDismissed(false), []);

// Skip synthetic enter/leave fired when a child changes visibility (relatedTarget stays inside the wrapper).
const handleMouseEnterAndLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const related = e.relatedTarget;
if (related instanceof Node && e.currentTarget.contains(related)) return;
setIsDismissed(false);
},
[]
);

const InlineTipWrapper = isHoverType ? ToolTipWrapper : InfoTipWrapper;
const InlineTipBodyWrapper = isHoverType
? ToolTipContainer
: InfoTipContainer;
const inlineWrapperProps = isHoverType ? {} : { hideTip: isTipHidden };
const tipWrapperProps = isHoverType ? ({ inheritDims } as const) : {};
const inlineWrapperProps = isHoverType
? { 'data-tooltip-body': '' }
: { hideTip: isTipHidden };
const tipWrapperProps = isHoverType
? { inheritDims, dismissed: isDismissed }
: {};
const tipBodyAlignment = getAlignmentStyles({ alignment, avatar, type });
const isHorizontalCenter = tipBodyAlignment === 'horizontalCenter';

Expand All @@ -46,6 +70,8 @@ export const InlineTip: React.FC<TipWrapperProps> = ({
height={inheritDims ? 'inherit' : undefined}
ref={wrapperRef}
width={inheritDims ? 'inherit' : undefined}
onBlur={isHoverType ? handleUndismissed : undefined}
onClick={isHoverType ? handleClick : undefined}
onKeyDown={escapeKeyPressHandler}
>
{children}
Expand Down Expand Up @@ -90,7 +116,11 @@ export const InlineTip: React.FC<TipWrapperProps> = ({
);

return (
<InlineTipWrapper {...tipWrapperProps}>
<InlineTipWrapper
{...(tipWrapperProps as any)}
onMouseEnter={isHoverType ? handleMouseEnterAndLeave : undefined}
onMouseLeave={isHoverType ? handleMouseEnterAndLeave : undefined}
>
{alignment.includes('top') ? (
<>
{tipBody}
Expand Down
29 changes: 22 additions & 7 deletions packages/gamut/src/Tip/shared/elements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ const inlineTipStates = states({
inheritDims: { height: 'inherit', width: 'inherit' },
});

const toolTipWrapperStates = states({
dismissed: {
'&:hover > [data-tooltip-body], &:has(:focus-visible) > [data-tooltip-body]':
{
opacity: 0,
visibility: 'hidden',
transition: 'none',
},
},
});

export const FloatingTipTextWrapper = styled(FlexBox)<
StyleProps<typeof floatingTipTextStates>
>(
Expand All @@ -43,16 +54,20 @@ export const FloatingTipTextWrapper = styled(FlexBox)<
floatingTipTextStates
);

export const ToolTipWrapper = styled.div<StyleProps<typeof inlineTipStates>>(
export const ToolTipWrapper = styled.div<
StyleProps<typeof inlineTipStates> & StyleProps<typeof toolTipWrapperStates>
>(
css({
'&:hover > div, &:focus-within > div': {
opacity: 1,
transition: `opacity ${timing.fast} ${timing.base}`,
visibility: 'visible',
},
'&:hover > [data-tooltip-body], &:has(:focus-visible) > [data-tooltip-body]':
{
opacity: 1,
transition: `opacity ${timing.fast} ${timing.base}`,
visibility: 'visible',
},
...tipWrapperStyles,
}),
inlineTipStates
inlineTipStates,
toolTipWrapperStates
);

export const InfoTipWrapper = styled.div(
Expand Down
1 change: 1 addition & 0 deletions packages/gamut/src/Tip/shared/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export type TipPlacementComponentProps = Omit<
contentRef?:
| React.RefObject<HTMLDivElement>
| ((node: HTMLDivElement | null) => void);
closeOnClick?: boolean;
type: 'info' | 'tool' | 'preview';
wrapperRef?: React.RefObject<HTMLDivElement>;
zIndex?: number;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ Use for secondary or space-constrained actions with recognizable icons. Use regu

For more detailed information on `IconButton` tooltips, take a look at the <LinkTo id='Molecules/Tips/ToolTip'>ToolTip</LinkTo> story.

## Close on Click

By default, `IconButton` tooltips close immediately on click and reappear only after the cursor leaves and re-enters. To persist the tooltip after a click, pass `tipProps={{ closeOnClick: false }}`. The first two buttons below close on click; the last two stay open.

<Canvas of={IconButtonStories.CloseOnClick} />

## Playground

<Canvas sourceState="shown" of={IconButtonStories.Default} />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { IconButton } from '@codecademy/gamut';
import { FlexBox, IconButton } from '@codecademy/gamut';
import { SparkleIcon } from '@codecademy/gamut-icons';
import * as icons from '@codecademy/gamut-icons';
import type { Meta, StoryObj } from '@storybook/react';
import type { TypeWithDeepControls } from 'storybook-addon-deep-controls';
Expand Down Expand Up @@ -48,3 +49,45 @@ type Story = StoryObj<typeof IconButton>;
export const Default: Story = {
args: {},
};

// eslint-disable-next-line no-console
const logClick = () => console.log('button onClick fired');

export const CloseOnClick: Story = {
render: () => (
<FlexBox center justifyContent="space-around" pt={48}>
<IconButton
icon={SparkleIcon}
tip="Closes on click (floating)"
tipProps={{ placement: 'floating', alignment: 'top-center' }}
onClick={logClick}
/>
<IconButton
icon={SparkleIcon}
tip="Closes on click (inline)"
tipProps={{ placement: 'inline', alignment: 'top-center' }}
onClick={logClick}
/>
<IconButton
icon={SparkleIcon}
tip="Stays open on click (floating)"
tipProps={{
placement: 'floating',
alignment: 'top-center',
closeOnClick: false,
}}
onClick={logClick}
/>
<IconButton
icon={SparkleIcon}
tip="Stays open on click (inline)"
tipProps={{
placement: 'inline',
alignment: 'top-center',
closeOnClick: false,
}}
onClick={logClick}
/>
</FlexBox>
),
};
10 changes: 7 additions & 3 deletions packages/styleguide/src/lib/Molecules/Menu/Menu.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,14 +262,18 @@ export const IconMenu: Story = {
<>
<MenuItem icon={AiChatSparkIcon} label="Chat" onClick={() => {}} />
<MenuItem
href="#whatsup"
icon={BashShellIcon}
label={{ alignment: 'right-center', info: 'Prompt' }}
onClick={() => {}}
/>
<MenuItem
href="#whatsup-people"
closeOnClick={false}
icon={PeopleIcon}
label={{ alignment: 'right-center', info: 'People' }}
label={{
alignment: 'right-center',
info: "People (won't close tooltip on click)",
}}
onClick={() => {}}
/>
<MenuItem
active
Expand Down
Loading
Loading