From 3e860a22fcc4a42bc317bc96e9f165a3298fefbe Mon Sep 17 00:00:00 2001 From: yugalkaushik Date: Sun, 4 Jan 2026 02:06:20 +0000 Subject: [PATCH 1/4] common tooltip Signed-off-by: yugalkaushik --- client/common/Tooltip.tsx | 79 ++++++++++++++++++++ client/components/Menubar/MenubarItem.tsx | 35 ++++++--- client/modules/IDE/components/Header/Nav.jsx | 32 +++++++- client/modules/IDE/components/Sidebar.jsx | 26 ++++++- client/styles/components/_nav.scss | 5 +- client/styles/components/_tooltip.scss | 31 ++++++++ client/styles/main.scss | 1 + translations/locales/en-US/translations.json | 11 ++- 8 files changed, 200 insertions(+), 20 deletions(-) create mode 100644 client/common/Tooltip.tsx create mode 100644 client/styles/components/_tooltip.scss diff --git a/client/common/Tooltip.tsx b/client/common/Tooltip.tsx new file mode 100644 index 0000000000..f5e5778c3d --- /dev/null +++ b/client/common/Tooltip.tsx @@ -0,0 +1,79 @@ +import React, { ReactElement, useRef, useState } from 'react'; + +export type TooltipDirection = 'n' | 's' | 'e' | 'w'; + +export type TooltipProps = { + content: string; + direction?: TooltipDirection; + noDelay?: boolean; + children: ReactElement; +}; + +export function Tooltip({ + content, + direction = 'n', + noDelay = false, + children +}: TooltipProps) { + const [open, setOpen] = useState(false); + const tooltipIdRef = useRef(`tooltip-${Math.random().toString(36).slice(2)}`); + + const childProps: Record = { + 'aria-label': content, + className: [ + (children.props && children.props.className) || '', + 'tooltipped', + `tooltipped-${direction}`, + noDelay ? 'tooltipped-no-delay' : '' + ] + .filter(Boolean) + .join(' '), + 'aria-describedby': tooltipIdRef.current, + onFocus: (e: React.FocusEvent) => { + setOpen(true); + if (children.props && typeof children.props.onFocus === 'function') { + children.props.onFocus(e); + } + }, + onBlur: (e: React.FocusEvent) => { + setOpen(false); + if (children.props && typeof children.props.onBlur === 'function') { + children.props.onBlur(e); + } + }, + onMouseEnter: (e: React.MouseEvent) => { + setOpen(true); + if (children.props && typeof children.props.onMouseEnter === 'function') { + children.props.onMouseEnter(e); + } + }, + onMouseLeave: (e: React.MouseEvent) => { + setOpen(false); + if (children.props && typeof children.props.onMouseLeave === 'function') { + children.props.onMouseLeave(e); + } + }, + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setOpen(false); + (e.target as HTMLElement)?.blur(); + } + if (children.props && typeof children.props.onKeyDown === 'function') { + children.props.onKeyDown(e); + } + } + }; + + const trigger = React.cloneElement(children, childProps); + + return ( + + {trigger} + {open && ( + + {content} + + )} + + ); +} diff --git a/client/components/Menubar/MenubarItem.tsx b/client/components/Menubar/MenubarItem.tsx index d2b3a1b1a3..9b69d24304 100644 --- a/client/components/Menubar/MenubarItem.tsx +++ b/client/components/Menubar/MenubarItem.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useContext, useRef } from 'react'; import { MenubarContext, SubmenuContext, ParentMenuContext } from './contexts'; import { ButtonOrLink, ButtonOrLinkProps } from '../../common/ButtonOrLink'; +import { Tooltip, TooltipDirection } from '../../common/Tooltip'; export enum MenubarItemRole { MENU_ITEM = 'menuitem', @@ -13,6 +14,8 @@ export interface MenubarItemProps extends Omit { */ role?: MenubarItemRole; selected?: boolean; + tooltipContent?: string; + tooltipDirection?: TooltipDirection; } /** @@ -54,6 +57,8 @@ export function MenubarItem({ role: customRole = MenubarItemRole.MENU_ITEM, isDisabled = false, selected = false, + tooltipContent, + tooltipDirection, ...rest }: MenubarItemProps) { const { createMenuItemHandlers, hasFocus } = useContext(MenubarContext); @@ -86,6 +91,26 @@ export function MenubarItem({ return unregister; }, [submenuItems, registerSubmenuItem]); + const buttonOrLink = ( + + ); + + const content = tooltipContent ? ( + + {buttonOrLink} + + ) : ( + buttonOrLink + ); + return (
  • - + {content}
  • ); } diff --git a/client/modules/IDE/components/Header/Nav.jsx b/client/modules/IDE/components/Header/Nav.jsx index 28995a2d56..48e0935ae9 100644 --- a/client/modules/IDE/components/Header/Nav.jsx +++ b/client/modules/IDE/components/Header/Nav.jsx @@ -185,7 +185,12 @@ const ProjectMenu = () => { isDisabled={ !user.authenticated || !isLoginEnabled || - (project?.owner && !isUserOwner) + (!!project?.owner && !isUserOwner) + } + tooltipContent={ + !user.authenticated || !isLoginEnabled + ? t('Nav.File.SaveTooltipUnauthenticated') + : undefined } onClick={() => saveSketch(cmRef.current)} > @@ -194,7 +199,12 @@ const ProjectMenu = () => { dispatch(cloneProject())} > {t('Nav.File.Duplicate')} @@ -202,6 +212,9 @@ const ProjectMenu = () => { {t('Nav.File.Share')} @@ -209,6 +222,9 @@ const ProjectMenu = () => { {t('Nav.File.Download')} @@ -216,6 +232,11 @@ const ProjectMenu = () => { {t('Nav.File.Open')} @@ -223,7 +244,12 @@ const ProjectMenu = () => { diff --git a/client/modules/IDE/components/Sidebar.jsx b/client/modules/IDE/components/Sidebar.jsx index 24fd487c9a..a7f6169bf3 100644 --- a/client/modules/IDE/components/Sidebar.jsx +++ b/client/modules/IDE/components/Sidebar.jsx @@ -16,6 +16,7 @@ import { getAuthenticated, selectCanEditSketch } from '../selectors/users'; import ConnectedFileNode from './FileNode'; import { PlusIcon } from '../../../common/icons'; import { FileDrawer } from './Editor/MobileEditor'; +import { Tooltip } from '../../../common/Tooltip'; // TODO: use a generic Dropdown UI component @@ -124,8 +125,8 @@ export default function SideBar() { {t('Sidebar.AddFile')} - {isAuthenticated && ( -
  • +
  • + {isAuthenticated ? ( -
  • - )} + ) : ( + + + + )} + )} diff --git a/client/styles/components/_nav.scss b/client/styles/components/_nav.scss index 5d597596ac..dfd08cf4c2 100644 --- a/client/styles/components/_nav.scss +++ b/client/styles/components/_nav.scss @@ -174,8 +174,8 @@ .nav__dropdown { @extend %dropdown-open-left; display: none; - max-height: 60vh; - overflow-y: auto; + max-height: none; + overflow: visible; .nav__item--open & { display: flex; } @@ -253,6 +253,7 @@ .nav__keyboard-shortcut { font-size: #{math.div(12, $base-font-size)}rem; font-family: Inconsololata, monospace; + margin-left: auto; @include themify() { color: getThemifyVariable('keyboard-shortcut-color'); diff --git a/client/styles/components/_tooltip.scss b/client/styles/components/_tooltip.scss new file mode 100644 index 0000000000..d4249fc443 --- /dev/null +++ b/client/styles/components/_tooltip.scss @@ -0,0 +1,31 @@ +.tooltip-wrapper { + position: relative; + display: inline-flex; +} + +.tooltip-wrapper .tooltipped::after { + @include themify() { + background-color: getThemifyVariable('button-background-hover-color'); + color: getThemifyVariable('button-hover-color'); + } + font-family: Montserrat, sans-serif; + font-size: 1rem; + padding: 0.5rem 0.75rem; + max-width: none; + white-space: nowrap; + left: 1rem; + right: auto; + transform: translateX(0); + text-align: left; +} + +.tooltip-wrapper .tooltipped-n::before, +.tooltip-wrapper .tooltipped::before { + @include themify() { + color: getThemifyVariable('button-background-hover-color'); + border-top-color: getThemifyVariable('button-background-hover-color'); + } + left: 1.75rem; + right: auto; + transform: translateX(0); +} diff --git a/client/styles/main.scss b/client/styles/main.scss index 3792c192f1..400da75bfb 100644 --- a/client/styles/main.scss +++ b/client/styles/main.scss @@ -58,6 +58,7 @@ @import 'components/admonition'; @import 'components/banner'; @import 'components/visibility-dropdown'; +@import 'components/tooltip'; @import 'layout/dashboard'; @import 'layout/ide'; \ No newline at end of file diff --git a/translations/locales/en-US/translations.json b/translations/locales/en-US/translations.json index 1d254a5afc..3198d1a42e 100644 --- a/translations/locales/en-US/translations.json +++ b/translations/locales/en-US/translations.json @@ -8,7 +8,13 @@ "Open": "Open", "Download": "Download", "AddToCollection": "Add to Collection", - "Examples": "Examples" + "Examples": "Examples", + "SaveTooltipUnauthenticated": "Log in to save your sketch", + "DuplicateTooltipUnauthenticated": "Log in to duplicate this sketch", + "OpenTooltipUnauthenticated": "Log in to open your sketches", + "AddToCollectionTooltipUnauthenticated": "Log in to add to collections", + "ShareTooltipUnsaved": "Save your sketch before sharing", + "DownloadTooltipUnsaved": "Save your sketch before downloading" }, "Edit": { "Title": "Edit", @@ -275,7 +281,8 @@ "AddFile": "Create file", "AddFileARIA": "add file", "UploadFile": "Upload file", - "UploadFileARIA": "upload file" + "UploadFileARIA": "upload file", + "UploadFileTooltipUnauthenticated": "Log in to upload files" }, "FileNode": { "OpenFolderARIA": "Open folder contents", From a60723243c6b1c6d48614484cec03fb97ff4960a Mon Sep 17 00:00:00 2001 From: yugalkaushik Date: Sun, 25 Jan 2026 09:48:28 +0000 Subject: [PATCH 2/4] update tooltip Signed-off-by: yugalkaushik --- client/common/Tooltip.test.tsx | 100 ++++++++++++++++++++++ client/common/Tooltip.tsx | 80 ++++------------- client/components/Menubar/MenubarItem.tsx | 10 +-- client/modules/IDE/components/Sidebar.jsx | 1 - client/styles/components/_nav.scss | 13 ++- client/styles/components/_tooltip.scss | 3 +- 6 files changed, 135 insertions(+), 72 deletions(-) create mode 100644 client/common/Tooltip.test.tsx diff --git a/client/common/Tooltip.test.tsx b/client/common/Tooltip.test.tsx new file mode 100644 index 0000000000..f1db9f1e39 --- /dev/null +++ b/client/common/Tooltip.test.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '../test-utils'; +import { Tooltip } from './Tooltip'; + +describe('Tooltip', () => { + it('renders the child element', () => { + render( + + + + ); + expect(screen.getByRole('button')).toBeInTheDocument(); + expect(screen.getByText('Hover me')).toBeInTheDocument(); + }); + + it('does not show the tooltip when the user is not hovering over the element', () => { + render( + + + + ); + + const button = screen.getByRole('button'); + expect(button).toBeInTheDocument(); + expect(button).not.toHaveClass('tooltipped-visible'); + }); + + it('shows the tooltip if the user hovers over the element', async () => { + const user = userEvent.setup(); + render( + + + + ); + + const button = screen.getByRole('button'); + await user.hover(button); + + expect(button).toHaveClass('tooltipped'); + expect(button).toHaveAttribute('aria-label', 'Tooltip text'); + }); + + it('adds the aria-label with tooltip content to the child element', () => { + render( + + + + ); + + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('aria-label', 'Save your changes'); + }); + + it('applies tooltipped-no-delay class when noDelay is true', () => { + render( + + + + ); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('tooltipped-no-delay'); + }); + + it('does not apply tooltipped-no-delay class when noDelay is false', () => { + render( + + + + ); + + const button = screen.getByRole('button'); + expect(button).not.toHaveClass('tooltipped-no-delay'); + }); + + it('preserves existing className on the child element', () => { + render( + + + + ); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('custom-class'); + expect(button).toHaveClass('tooltipped'); + }); + + it('wraps the child in a tooltip-wrapper span', () => { + const { container } = render( + + + + ); + + const wrapper = container.querySelector('.tooltip-wrapper'); + expect(wrapper).toBeInTheDocument(); + expect(wrapper?.tagName.toLowerCase()).toBe('span'); + }); +}); diff --git a/client/common/Tooltip.tsx b/client/common/Tooltip.tsx index f5e5778c3d..d9bf66c836 100644 --- a/client/common/Tooltip.tsx +++ b/client/common/Tooltip.tsx @@ -1,79 +1,35 @@ -import React, { ReactElement, useRef, useState } from 'react'; - -export type TooltipDirection = 'n' | 's' | 'e' | 'w'; +import React, { ReactElement, useMemo } from 'react'; export type TooltipProps = { content: string; - direction?: TooltipDirection; noDelay?: boolean; children: ReactElement; }; -export function Tooltip({ - content, - direction = 'n', - noDelay = false, - children -}: TooltipProps) { - const [open, setOpen] = useState(false); - const tooltipIdRef = useRef(`tooltip-${Math.random().toString(36).slice(2)}`); - - const childProps: Record = { - 'aria-label': content, - className: [ - (children.props && children.props.className) || '', +export function Tooltip({ content, noDelay = false, children }: TooltipProps) { + const tooltipClasses = useMemo(() => { + const existingClassName = children.props?.className || ''; + return [ + existingClassName, 'tooltipped', - `tooltipped-${direction}`, - noDelay ? 'tooltipped-no-delay' : '' + 'tooltipped-n', + noDelay && 'tooltipped-no-delay' ] .filter(Boolean) - .join(' '), - 'aria-describedby': tooltipIdRef.current, - onFocus: (e: React.FocusEvent) => { - setOpen(true); - if (children.props && typeof children.props.onFocus === 'function') { - children.props.onFocus(e); - } - }, - onBlur: (e: React.FocusEvent) => { - setOpen(false); - if (children.props && typeof children.props.onBlur === 'function') { - children.props.onBlur(e); - } - }, - onMouseEnter: (e: React.MouseEvent) => { - setOpen(true); - if (children.props && typeof children.props.onMouseEnter === 'function') { - children.props.onMouseEnter(e); - } - }, - onMouseLeave: (e: React.MouseEvent) => { - setOpen(false); - if (children.props && typeof children.props.onMouseLeave === 'function') { - children.props.onMouseLeave(e); - } - }, - onKeyDown: (e: React.KeyboardEvent) => { - if (e.key === 'Escape') { - setOpen(false); - (e.target as HTMLElement)?.blur(); - } - if (children.props && typeof children.props.onKeyDown === 'function') { - children.props.onKeyDown(e); - } - } - }; + .join(' '); + }, [children.props?.className, noDelay]); - const trigger = React.cloneElement(children, childProps); + const childProps = useMemo( + () => ({ + 'aria-label': content, + className: tooltipClasses + }), + [content, tooltipClasses] + ); return ( - {trigger} - {open && ( - - {content} - - )} + {React.cloneElement(children, childProps)} ); } diff --git a/client/components/Menubar/MenubarItem.tsx b/client/components/Menubar/MenubarItem.tsx index 9b69d24304..f0ce96a15b 100644 --- a/client/components/Menubar/MenubarItem.tsx +++ b/client/components/Menubar/MenubarItem.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useContext, useRef } from 'react'; import { MenubarContext, SubmenuContext, ParentMenuContext } from './contexts'; import { ButtonOrLink, ButtonOrLinkProps } from '../../common/ButtonOrLink'; -import { Tooltip, TooltipDirection } from '../../common/Tooltip'; +import { Tooltip, TooltipProps } from '../../common/Tooltip'; export enum MenubarItemRole { MENU_ITEM = 'menuitem', @@ -14,8 +14,7 @@ export interface MenubarItemProps extends Omit { */ role?: MenubarItemRole; selected?: boolean; - tooltipContent?: string; - tooltipDirection?: TooltipDirection; + tooltipContent?: TooltipProps['content']; } /** @@ -58,7 +57,6 @@ export function MenubarItem({ isDisabled = false, selected = false, tooltipContent, - tooltipDirection, ...rest }: MenubarItemProps) { const { createMenuItemHandlers, hasFocus } = useContext(MenubarContext); @@ -104,9 +102,7 @@ export function MenubarItem({ ); const content = tooltipContent ? ( - - {buttonOrLink} - + {buttonOrLink} ) : ( buttonOrLink ); diff --git a/client/modules/IDE/components/Sidebar.jsx b/client/modules/IDE/components/Sidebar.jsx index a7f6169bf3..7c6335689c 100644 --- a/client/modules/IDE/components/Sidebar.jsx +++ b/client/modules/IDE/components/Sidebar.jsx @@ -139,7 +139,6 @@ export default function SideBar() { ) : (