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 new file mode 100644 index 0000000000..d9bf66c836 --- /dev/null +++ b/client/common/Tooltip.tsx @@ -0,0 +1,35 @@ +import React, { ReactElement, useMemo } from 'react'; + +export type TooltipProps = { + content: string; + noDelay?: boolean; + children: ReactElement; +}; + +export function Tooltip({ content, noDelay = false, children }: TooltipProps) { + const tooltipClasses = useMemo(() => { + const existingClassName = children.props?.className || ''; + return [ + existingClassName, + 'tooltipped', + 'tooltipped-n', + noDelay && 'tooltipped-no-delay' + ] + .filter(Boolean) + .join(' '); + }, [children.props?.className, noDelay]); + + const childProps = useMemo( + () => ({ + 'aria-label': content, + className: tooltipClasses + }), + [content, tooltipClasses] + ); + + return ( + + {React.cloneElement(children, childProps)} + + ); +} diff --git a/client/components/Menubar/MenubarItem.tsx b/client/components/Menubar/MenubarItem.tsx index d2b3a1b1a3..def3568661 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, TooltipProps } from '../../common/Tooltip'; export enum MenubarItemRole { MENU_ITEM = 'menuitem', @@ -13,6 +14,7 @@ export interface MenubarItemProps extends Omit { */ role?: MenubarItemRole; selected?: boolean; + tooltipContent?: TooltipProps['content']; } /** @@ -54,6 +56,7 @@ export function MenubarItem({ role: customRole = MenubarItemRole.MENU_ITEM, isDisabled = false, selected = false, + tooltipContent, ...rest }: MenubarItemProps) { const { createMenuItemHandlers, hasFocus } = useContext(MenubarContext); @@ -94,15 +97,29 @@ export function MenubarItem({ ref={menuItemRef} onMouseEnter={handleMouseEnter} > - + {tooltipContent ? ( + + + + ) : ( + + )} ); } 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..7c6335689c 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/abstracts/_placeholders.scss b/client/styles/abstracts/_placeholders.scss index 65e115a38a..7d61d52cb8 100644 --- a/client/styles/abstracts/_placeholders.scss +++ b/client/styles/abstracts/_placeholders.scss @@ -230,7 +230,7 @@ background-color: getThemifyVariable('button-background-hover-color'); color: getThemifyVariable('button-hover-color') } - & button, & a { + & button, & a, & .tooltip-wrapper button { @include themify() { color: getThemifyVariable('button-hover-color'); } diff --git a/client/styles/components/_nav.scss b/client/styles/components/_nav.scss index 5d597596ac..887407db45 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; } @@ -212,13 +212,24 @@ .nav__dropdown-item { & button, - & a { + & a, + & .tooltip-wrapper button, + & .tooltip-wrapper a { width: 100%; height: 100%; display: flex; justify-content: space-between; align-items: center; } + + &:hover { + & .tooltip-wrapper button, + & .tooltip-wrapper a { + @include themify() { + color: getThemifyVariable('button-hover-color'); + } + } + } } .nav__item-logo { @@ -253,6 +264,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..8edc0b7156 --- /dev/null +++ b/client/styles/components/_tooltip.scss @@ -0,0 +1,32 @@ +.tooltip-wrapper { + position: relative; + display: flex; + width: 100%; +} + +.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",