Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
620661b
first attempt at closing tooltip after click
LinKCoding Jun 26, 2026
9b59cc5
removed zIndex from tip props
LinKCoding Jun 29, 2026
145c2c6
InlineToolTip now a state instead of style, added testing for onClick
LinKCoding Jun 29, 2026
7a201de
fix various bugs
LinKCoding Jun 29, 2026
8de8bd9
added stories
LinKCoding Jun 29, 2026
8e127a3
fix up errors
LinKCoding Jun 30, 2026
8f21e73
update MenuItem tooltip
LinKCoding Jun 30, 2026
9011495
update MenuItem type
LinKCoding Jun 30, 2026
087ec9d
updated supressed => dismissed
LinKCoding Jul 1, 2026
f4b3454
undoing some type changes
LinKCoding Jul 1, 2026
56b05ab
add version plan
LinKCoding Jul 1, 2026
8385f85
merged in tooltips branch
LinKCoding Jul 1, 2026
1da928e
update publish workflow
LinKCoding Jul 1, 2026
b91cd76
robo patch for now
LinKCoding Jul 1, 2026
27d8cb3
slight clean-up and added story for persisting tooltip in IconButton
LinKCoding Jul 1, 2026
7380e65
first attempt at closing tooltip after click
LinKCoding Jun 26, 2026
4656e6e
removed zIndex from tip props
LinKCoding Jun 29, 2026
22d8ca6
InlineToolTip now a state instead of style, added testing for onClick
LinKCoding Jun 29, 2026
3f3ebc0
fix various bugs
LinKCoding Jun 29, 2026
560c34a
added stories
LinKCoding Jun 29, 2026
4d036d2
fix up errors
LinKCoding Jun 30, 2026
1c5e743
update MenuItem tooltip
LinKCoding Jun 30, 2026
20078ff
update MenuItem type
LinKCoding Jun 30, 2026
02e1f2a
updated supressed => dismissed
LinKCoding Jul 1, 2026
3ea21c7
undoing some type changes
LinKCoding Jul 1, 2026
08d71d0
add version plan
LinKCoding Jul 1, 2026
2c47b8e
clean up prop and add new story
LinKCoding Jul 1, 2026
90c8362
move back closeOnClick and update more stories
LinKCoding Jul 1, 2026
ca58da4
merged in feature branch
LinKCoding Jul 1, 2026
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
2 changes: 2 additions & 0 deletions .github/workflows/publish-alpha.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ jobs:
runs-on: ubuntu-22.04
timeout-minutes: 30
steps:
- uses: actions/checkout@v4

- uses: ./.github/actions/prerelease-publish
with:
node-auth-token: ${{ secrets.NODE_AUTH_TOKEN }}
Expand Down
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 dismissable 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)}>
<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);
});

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 Down Expand Up @@ -88,6 +90,7 @@ export const MenuItem = forwardRef<
target,
width = 1,
'aria-label': explicitAriaLabel,
closeOnClick = true,
...props
},
ref
Expand Down Expand Up @@ -169,7 +172,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={narrowMenuItemRef<HTMLButtonElement>(ref)}
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>
);
};
2 changes: 1 addition & 1 deletion packages/gamut/src/Tag/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export const Tag: React.FC<TagProps> = ({
{isSelection && (
<DismissButton
aria-disabled={disabled}
aria-label={`Dismiss ${children} Tag`}
aria-label={`Dismiss ${children as string} Tag`}
disabled={disabled}
icon={isLarge ? LargeMiniDeleteIcon : DefaultMiniDeleteIcon}
isLarge={isLarge}
Expand Down
5 changes: 5 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;
/**
* 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 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 as React.Ref<HTMLDivElement>}
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';

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 as React.Ref<HTMLDivElement>}
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
5 changes: 4 additions & 1 deletion packages/gamut/src/Tip/shared/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ export type TipPlacementComponentProps = Omit<
escapeKeyPressHandler?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
id?: string;
isTipHidden?: boolean;
contentRef?: React.Ref<HTMLDivElement | null>;
contentRef?:
| React.RefObject<HTMLDivElement>
| ((node: HTMLDivElement | null) => void);
closeOnClick?: boolean;
type: 'info' | 'tool' | 'preview';
wrapperRef?: React.Ref<HTMLDivElement | null>;
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