diff --git a/.github/workflows/publish-alpha.yml b/.github/workflows/publish-alpha.yml index b968dd17558..e4c5830d8a6 100644 --- a/.github/workflows/publish-alpha.yml +++ b/.github/workflows/publish-alpha.yml @@ -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 }} diff --git a/.nx/version-plans/version-plan-1782928816938.md b/.nx/version-plans/version-plan-1782928816938.md new file mode 100644 index 00000000000..e81bc503819 --- /dev/null +++ b/.nx/version-plans/version-plan-1782928816938.md @@ -0,0 +1,5 @@ +--- +gamut: patch +--- + +enable dismissable of ToolTip via onClick for interactive components like IconButton, MenuItem (icon-only), and FloatingToolTip + InlineToolTip diff --git a/packages/gamut/src/Button/IconButton.tsx b/packages/gamut/src/Button/IconButton.tsx index fc5301765ff..1e200229215 100644 --- a/packages/gamut/src/Button/IconButton.tsx +++ b/packages/gamut/src/Button/IconButton.tsx @@ -40,7 +40,7 @@ export const IconButton = forwardRef( const iconSize = iconSizeMapping[buttonSize]; return ( - + { 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); + }); + }); }); diff --git a/packages/gamut/src/Menu/MenuItem.tsx b/packages/gamut/src/Menu/MenuItem.tsx index d8ba9af3fd9..add401014f4 100644 --- a/packages/gamut/src/Menu/MenuItem.tsx +++ b/packages/gamut/src/Menu/MenuItem.tsx @@ -49,6 +49,7 @@ 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 { @@ -56,6 +57,7 @@ interface MenuTextItem extends HTMLProps, ForwardListItemProps { children: React.ReactNode; label?: ToolTipLabel; disabled?: boolean; + closeOnClick?: never; } type MenuItemTypes = MenuItemIconOnly | MenuTextItem; @@ -88,6 +90,7 @@ export const MenuItem = forwardRef< target, width = 1, 'aria-label': explicitAriaLabel, + closeOnClick = true, ...props }, ref @@ -169,7 +172,11 @@ export const MenuItem = forwardRef< return ( - + (ref)} diff --git a/packages/gamut/src/Menu/elements.tsx b/packages/gamut/src/Menu/elements.tsx index f8eea4af20d..47aa6820117 100644 --- a/packages/gamut/src/Menu/elements.tsx +++ b/packages/gamut/src/Menu/elements.tsx @@ -243,8 +243,9 @@ export const ListButton = styled( export const MenuToolTipWrapper: React.FC< Pick, 'children' | 'label'> & { tipId: string; + closeOnClick?: boolean; } -> = ({ children, label, tipId }) => { +> = ({ children, label, tipId, closeOnClick }) => { if (!label) { return <>{children}; } @@ -267,5 +268,9 @@ export const MenuToolTipWrapper: React.FC< ...defaultTipProps, }; - return {children}; + return ( + + {children} + + ); }; diff --git a/packages/gamut/src/Tip/ToolTip/index.tsx b/packages/gamut/src/Tip/ToolTip/index.tsx index 0c9dbf78b71..9b5ef322131 100644 --- a/packages/gamut/src/Tip/ToolTip/index.tsx +++ b/packages/gamut/src/Tip/ToolTip/index.tsx @@ -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. */ @@ -21,6 +26,7 @@ export type ToolTipProps = TipBaseProps & export const ToolTip: React.FC = ({ alignment = 'top-center', + closeOnClick = true, children, info, placement = tipDefaultProps.placement, @@ -39,6 +45,7 @@ export const ToolTip: React.FC = ({ const tipProps = { alignment, + closeOnClick, info, wrapperRef, ...rest, diff --git a/packages/gamut/src/Tip/shared/FloatingTip.tsx b/packages/gamut/src/Tip/shared/FloatingTip.tsx index ce1a8541d49..3a02df77d82 100644 --- a/packages/gamut/src/Tip/shared/FloatingTip.tsx +++ b/packages/gamut/src/Tip/shared/FloatingTip.tsx @@ -24,6 +24,7 @@ export const FloatingTip: React.FC = ({ alignment, avatar, children, + closeOnClick, escapeKeyPressHandler, inheritDims, info, @@ -122,6 +123,21 @@ export const FloatingTip: React.FC = ({ ? (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 ? ( = ({ ref={ref as React.Ref} width={inheritDims ? 'inherit' : undefined} onBlur={toolOnlyEventFunc} + onClick={clickHandler} onFocus={toolOnlyEventFunc} onKeyDown={escapeKeyPressHandler} onMouseDown={(e) => e.preventDefault()} diff --git a/packages/gamut/src/Tip/shared/InlineTip.tsx b/packages/gamut/src/Tip/shared/InlineTip.tsx index 230ef200950..5b3ad0c6480 100644 --- a/packages/gamut/src/Tip/shared/InlineTip.tsx +++ b/packages/gamut/src/Tip/shared/InlineTip.tsx @@ -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'; @@ -15,6 +17,7 @@ export const InlineTip: React.FC = ({ alignment, avatar, children, + closeOnClick, escapeKeyPressHandler, id, inheritDims, @@ -31,13 +34,34 @@ export const InlineTip: React.FC = ({ 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) => { + 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'; @@ -46,6 +70,8 @@ export const InlineTip: React.FC = ({ height={inheritDims ? 'inherit' : undefined} ref={wrapperRef as React.Ref} width={inheritDims ? 'inherit' : undefined} + onBlur={isHoverType ? handleUndismissed : undefined} + onClick={isHoverType ? handleClick : undefined} onKeyDown={escapeKeyPressHandler} > {children} @@ -90,7 +116,11 @@ export const InlineTip: React.FC = ({ ); return ( - + {alignment.includes('top') ? ( <> {tipBody} diff --git a/packages/gamut/src/Tip/shared/elements.tsx b/packages/gamut/src/Tip/shared/elements.tsx index 8a510fea0bf..8b5ab1ef6de 100644 --- a/packages/gamut/src/Tip/shared/elements.tsx +++ b/packages/gamut/src/Tip/shared/elements.tsx @@ -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 >( @@ -43,16 +54,20 @@ export const FloatingTipTextWrapper = styled(FlexBox)< floatingTipTextStates ); -export const ToolTipWrapper = styled.div>( +export const ToolTipWrapper = styled.div< + StyleProps & StyleProps +>( 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( diff --git a/packages/gamut/src/Tip/shared/types.tsx b/packages/gamut/src/Tip/shared/types.tsx index 32299dd678e..74a075f6819 100644 --- a/packages/gamut/src/Tip/shared/types.tsx +++ b/packages/gamut/src/Tip/shared/types.tsx @@ -78,7 +78,10 @@ export type TipPlacementComponentProps = Omit< escapeKeyPressHandler?: (event: React.KeyboardEvent) => void; id?: string; isTipHidden?: boolean; - contentRef?: React.Ref; + contentRef?: + | React.RefObject + | ((node: HTMLDivElement | null) => void); + closeOnClick?: boolean; type: 'info' | 'tool' | 'preview'; wrapperRef?: React.Ref; zIndex?: number; diff --git a/packages/styleguide/src/lib/Atoms/Buttons/IconButton/IconButton.mdx b/packages/styleguide/src/lib/Atoms/Buttons/IconButton/IconButton.mdx index 6c21691cfcc..5f53eac1990 100644 --- a/packages/styleguide/src/lib/Atoms/Buttons/IconButton/IconButton.mdx +++ b/packages/styleguide/src/lib/Atoms/Buttons/IconButton/IconButton.mdx @@ -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 ToolTip 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. + + + ## Playground diff --git a/packages/styleguide/src/lib/Atoms/Buttons/IconButton/IconButton.stories.tsx b/packages/styleguide/src/lib/Atoms/Buttons/IconButton/IconButton.stories.tsx index 9c9f8b41612..63b5cf62ba4 100644 --- a/packages/styleguide/src/lib/Atoms/Buttons/IconButton/IconButton.stories.tsx +++ b/packages/styleguide/src/lib/Atoms/Buttons/IconButton/IconButton.stories.tsx @@ -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'; @@ -48,3 +49,45 @@ type Story = StoryObj; export const Default: Story = { args: {}, }; + +// eslint-disable-next-line no-console +const logClick = () => console.log('button onClick fired'); + +export const CloseOnClick: Story = { + render: () => ( + + + + + + + ), +}; diff --git a/packages/styleguide/src/lib/Molecules/Menu/Menu.stories.tsx b/packages/styleguide/src/lib/Molecules/Menu/Menu.stories.tsx index cc5a1942b24..38e94d44933 100644 --- a/packages/styleguide/src/lib/Molecules/Menu/Menu.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/Menu/Menu.stories.tsx @@ -262,14 +262,18 @@ export const IconMenu: Story = { <> {}} /> {}} /> {}} /> +### Close on click + +By default, `ToolTip` closes on click and reappears only after the cursor leaves and re-enters. Pass `closeOnClick={false}` to opt out (e.g. copy → copied patterns). The first two buttons close on click; the last two stay open. + + + ## Playground diff --git a/packages/styleguide/src/lib/Molecules/Tips/ToolTip/ToolTip.stories.tsx b/packages/styleguide/src/lib/Molecules/Tips/ToolTip/ToolTip.stories.tsx index f99c63dfc4e..d996b259c90 100644 --- a/packages/styleguide/src/lib/Molecules/Tips/ToolTip/ToolTip.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/Tips/ToolTip/ToolTip.stories.tsx @@ -155,6 +155,47 @@ export const Disabled: Story = { ), }; +export const CloseOnClick: Story = { + render: () => ( + + + Floating + + + Inline + + + + Floating (stays open) + + + + + Inline (stays open) + + + + ), +}; + export const HorizontalAlignments: Story = { render: () => ( <>